Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.{sh,bash,bats}]
indent_style = tab

[*.{yml,yaml,md}]
indent_style = space
indent_size = 2

[*.md]
trim_trailing_whitespace = false
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
bats:
name: "bats: ${{ matrix.os }}"
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Fixed

- The managed-block editor replaced the dotfile with a new file via `mv`,
which turned a symlinked `~/.bashrc` (e.g. from a dotfiles manager) into a
regular file and reset its mode to 0600. It now rewrites the file in place.
- With the block's end marker missing, every line after the start marker was
treated as block content and deleted. Damaged markers (missing, duplicated
or out-of-order) now fail the step and leave the file untouched
(`managed_block_apply` returns 2).
- `--force shell-profile` (a README example) failed whenever the block was
already up to date, because the editor's "unchanged" status was treated as
an apply failure.
- `--dry-run --force <step>` removed the step from the state file.
- On a fresh Debian/Ubuntu system every package install failed with "Unable
to locate package": the `apt` backend now runs `apt-get update` once per
run (non-interactively) before installing.
- A missing option value exited 1 instead of the documented 3; profile names
are validated (letters, digits, `-`, `_`); `--force` with a step that is not
in the profile is an error (3) instead of a silent no-op. All are rejected
before the log or state file is written.
- README: the clone URL pointed at `example.com`.

### Added

- Regression tests for each fix; `SECURITY.md`, `.editorconfig`,
`docs/PRODUCTION_READINESS.md`; CI `permissions: contents: read`; README
environment-variable table and limitations.

## [0.1.0] - 2026-08-30

### Added
Expand Down
41 changes: 37 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ summary.
no log directory changes beyond the log itself, and no dotfile edits.
- Package manager abstraction (`lib/pkg.sh`): detects `apt`, `dnf`, `pacman`,
or `brew` and dispatches to it; fails with a clear error on an unsupported
platform rather than guessing.
platform rather than guessing. On `apt` it refreshes the package lists once
per run before the first install (a fresh system has none).
- Profiles (`profiles/*.conf`): a profile is a plain list of step ids,
selected with `--profile`.
- Preflight checks (`lib/preflight.sh`) that run before anything changes:
Expand All @@ -100,7 +101,7 @@ List them at any time with `--list-profiles`; list every known step with
## Installation

```sh
git clone https://example.com/bootstrap-dev.git
git clone https://github.com/hellpuffyt/bootstrap-dev.git
cd bootstrap-dev
chmod +x bootstrap.sh
```
Expand Down Expand Up @@ -145,7 +146,19 @@ Options:
```

Exit codes: `0` success, `1` a step failed, `2` preflight failed, `3` a bad
argument or unknown profile.
argument (including a missing option value), an unknown or invalid profile
name, or `--force` naming a step that is not in the profile.

Environment variables (all optional):

| Variable | Effect |
|---|---|
| `BOOTSTRAP_SKIP_NETWORK_CHECK=1` | Skip the preflight HTTPS probe of github.com. |
| `BOOTSTRAP_SKIP_SUDO_CHECK=1` | Skip the preflight sudo check. |
| `BOOTSTRAP_MIN_DISK_MB` | Free space required by preflight (default 500). |
| `BOOTSTRAP_CONFLICT_BINS` | Space-separated binaries whose presence fails preflight. |
| `BOOTSTRAP_SHELL_PROFILE_FILE` | File the `shell-profile` step edits (default `~/.bashrc`). |
| `STATE_FILE` / `LOG_FILE_PATH` | Same as `--state-file` / `--log-file`. |

## Writing a step

Expand Down Expand Up @@ -204,7 +217,10 @@ Rules for a good step:
no-op).
- Dotfile edits go through managed blocks, never raw appends: the marker
comments make the boundary of what `bootstrap-dev` owns explicit and
removable.
removable. The file is rewritten in place, so a symlinked dotfile stays a
symlink and keeps its permissions. If the markers are damaged (an end
marker missing, duplicated or out of order), the step fails and the file
is left untouched rather than guessing where the block ends.
- A failing `verify` fails the whole run immediately rather than continuing
past a step that silently didn't take effect.
- `--dry-run` never calls a step's real `apply` path — steps receive
Expand Down Expand Up @@ -249,6 +265,8 @@ and preflight checks including their failure paths.

## Security

To report a vulnerability, see [SECURITY.md](SECURITY.md).

- `bootstrap-dev` never pipes a fetched script straight into a shell
(`curl ... | bash`) from an unpinned URL. If a step needs to fetch
something, the rule is: pin the URL to a specific version and verify a
Expand All @@ -262,6 +280,21 @@ and preflight checks including their failure paths.
- `set -euo pipefail` throughout, all variable expansions quoted, no `eval`
of untrusted input anywhere in the codebase.

## Limitations

- Verified end to end only in throwaway containers (Ubuntu 24.04 with `apt`,
Fedora with `dnf`, see `docs/PRODUCTION_READINESS.md`); `pacman` and
`brew` dispatch is covered by stubbed tests only. It has not been run
against a real macOS or desktop Linux install.
- `shell-profile` edits `~/.bashrc` by default. On macOS the default login
shell is zsh, which does not read it; point `BOOTSTRAP_SHELL_PROFILE_FILE`
at `~/.zshrc` there.
- Steps are run inside `if`, where bash disables `set -e`; a multi-command
`apply` can therefore continue past a failing command. The separate
`verify` call is what catches a step that did not take effect.
- No locking: two concurrent runs against the same state file are not
supported.

## License

MIT, see [LICENSE](LICENSE).
20 changes: 20 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Security policy

## Supported versions

Only the latest commit on `main` is supported.

## Reporting a vulnerability

Please report security issues privately through GitHub:
https://github.com/hellpuffyt/bootstrap-dev/security/advisories/new

Do not open a public issue for a vulnerability.

## Scope

bootstrap-dev installs packages and edits dotfiles on the machine it runs on,
using `sudo` only for the package-manager call. In scope: anything that makes
it change more than its steps declare, run during `--dry-run`, lose or corrupt
user data in edited files, escalate privileges beyond the package-manager
call, or fetch and execute unverified content.
56 changes: 44 additions & 12 deletions bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,21 @@ Options:
EOF
}

# Every option that takes a value goes through this, so a missing value is a
# bad argument (exit 3) rather than a bare ${2:?} shell error (exit 1).
need_value() {
if [ "$2" -lt 2 ] || [ -z "$3" ]; then
printf '%s requires a value\n' "$1" >&2
usage >&2
exit 3
fi
}

while [ "$#" -gt 0 ]; do
case "$1" in
--profile)
PROFILE="${2:?--profile requires a value}"
need_value --profile "$#" "${2:-}"
PROFILE="$2"
shift 2
;;
--dry-run)
Expand All @@ -60,15 +71,18 @@ while [ "$#" -gt 0 ]; do
shift
;;
--force)
FORCE_STEP="${2:?--force requires a step name}"
need_value --force "$#" "${2:-}"
FORCE_STEP="$2"
shift 2
;;
--state-file)
STATE_FILE="${2:?--state-file requires a value}"
need_value --state-file "$#" "${2:-}"
STATE_FILE="$2"
shift 2
;;
--log-file)
LOG_FILE_PATH="${2:?--log-file requires a value}"
need_value --log-file "$#" "${2:-}"
LOG_FILE_PATH="$2"
shift 2
;;
--skip-preflight)
Expand Down Expand Up @@ -119,12 +133,38 @@ if [ "$LIST_STEPS" -eq 1 ]; then
exit 0
fi

case "$PROFILE" in
"" | *[!A-Za-z0-9_-]*)
printf 'invalid profile name: %s (letters, digits, - and _ only)\n' "$PROFILE" >&2
exit 3
;;
esac

PROFILE_FILE="$BOOTSTRAP_ROOT/profiles/${PROFILE}.conf"
if [ ! -f "$PROFILE_FILE" ]; then
printf 'unknown profile: %s (looked for %s)\n' "$PROFILE" "$PROFILE_FILE" >&2
exit 3
fi

# Read with a loop rather than `mapfile`: mapfile is a bash 4 builtin and
# macOS still ships bash 3.2, where it does not exist. A bootstrap tool that
# cannot run on macOS is not much of a bootstrap tool.
STEP_IDS=()
while IFS= read -r _line; do
STEP_IDS+=("$_line")
done < <(grep -Ev '^[[:space:]]*(#|$)' "$PROFILE_FILE")

if [ -n "$FORCE_STEP" ]; then
_force_in_profile=0
for id in "${STEP_IDS[@]}"; do
[ "$id" = "$FORCE_STEP" ] && _force_in_profile=1
done
if [ "$_force_in_profile" -ne 1 ]; then
printf -- '--force %s: no such step in profile %s (steps: %s)\n' "$FORCE_STEP" "$PROFILE" "${STEP_IDS[*]}" >&2
exit 3
fi
fi

log_init "$LOG_FILE_PATH"
log_info "bootstrap-dev starting: profile=$PROFILE dry_run=$DRY_RUN resume=$RESUME_MODE"

Expand All @@ -139,14 +179,6 @@ if [ "$DRY_RUN" -ne 1 ]; then
state_init
fi

# Read with a loop rather than `mapfile`: mapfile is a bash 4 builtin and
# macOS still ships bash 3.2, where it does not exist. A bootstrap tool that
# cannot run on macOS is not much of a bootstrap tool.
STEP_IDS=()
while IFS= read -r _line; do
STEP_IDS+=("$_line")
done < <(grep -Ev '^[[:space:]]*(#|$)' "$PROFILE_FILE")

exit_code=0
for id in "${STEP_IDS[@]}"; do
if ! run_step "$id"; then
Expand Down
Loading
Loading