diff --git a/.agents/skills/devboost-module-author/SKILL.md b/.agents/skills/devboost-module-author/SKILL.md new file mode 100644 index 0000000..16910cf --- /dev/null +++ b/.agents/skills/devboost-module-author/SKILL.md @@ -0,0 +1,164 @@ +--- +name: devboost-module-author +description: This skill should be used when adding a new module to devboost, changing which tool/default a devboost module uses, or writing/updating a module's rationale doc comment. Covers researching developer-community consensus before picking a default and documenting that research in the code. +version: 1.0.0 +--- + +# Authoring devboost modules + +## Overview + +A devboost module is an opinionated default: "here is the tool we picked for +this job, and here is exactly how we configure it." Because devboost's whole +pitch is "new machine, run devboost, start coding in minutes" — a developer +trusts these choices without re-deriving them — every module needs both a +correct implementation and an honest, sourced explanation of why that choice +was made. This skill covers both halves: the research process, and how to +write it into the code so it survives as documentation, not just a commit +message. + +This mirrors the project's own written principle (see `AGENTS.md` / +`CLAUDE.md`, "Research before defaulting"): before choosing a default value, +research actual developer-community preferences, don't just carry over +whatever the tool's own installer defaults to. + +## When a module needs research + +Any time you are: + +- Adding a brand-new module that picks a specific tool (a new CLI utility, + a new config default, a new "we install X instead of Y"). +- Changing an existing module's tool choice or a specific config value. +- Writing/backfilling the rationale doc comment on an existing module that + doesn't have one yet. + +You do **not** need fresh research for pure mechanism/plumbing code (file +rendering, dependency wiring, CLI flag parsing) — the research obligation is +specifically about *which tool* and *which default value*, not the Go code +that applies it. + +## Research checklist + +For each tool-choice or default value, before writing the module: + +1. **Check adoption/reputation, not just familiarity.** GitHub stars, last + commit/push date, and whether the project is actively maintained are + fast, checkable signals. Prefer a live check (`gh api + repos//` or WebFetch) over memory — package popularity and + maintenance status genuinely change over time, and stale assumptions are + exactly what this whole skill exists to prevent. +2. **Prefer first-party sources over third-party blog consensus when they + conflict.** If tool A officially documents "the recommended way to use us + with tool B is X," that beats a five-year-old blog post recommending Y, + even if Y is more commonly copy-pasted. Go read the tool's own docs + before trusting general "best practice" writeups. +3. **Check whether the choice is still current, not just whether it was + ever correct.** Tools get deprecated, unbundled, or superseded (see + corepack's unbundling from Node 25+, found during this project's own + module-documentation pass). A default that was right when first chosen + can silently become wrong. Look for the tool's own changelog/release + notes/roadmap discussion, not just "does the tool still exist." +4. **Distinguish genuine consensus from a plausible-sounding default.** Some + config values have real documented justification (tmux's + `escape-time = 0` fixing a specific, widely-reported perceived-lag + problem). Others are just "a reasonable choice nobody strongly + disagrees with" (a prompt's truncation length). Both are fine to ship — + but the doc comment must say which kind it is. Never write confident + prose for an undocumented preference. +5. **When your own investigation produced the evidence, use that.** + devboost's dedup modules exist because this project's own instrumented + measurement (zprof) found nvm's shell hook costing ~850-900ms per login + shell — that's stronger evidence than any external survey. If you did + the measurement, cite the measurement. + +## Writing the doc comment + +Put the rationale directly above the module's exported constructor function +(the `func Foo(cfg *config.Config) []engine.Resource` — see +`engine/modules/git.go` or `engine/modules/tmux.go` for real examples), as a +Go doc comment. Requirements: + +- **State the why, not just the what.** "Git ports modules/module_git.sh" + describes behavior; "delta is the clear community-favorite modern git + pager — 31.7k stars vs. diff-so-fancy's 18.1k, checked live" is rationale. + Both belong in the comment, but the rationale is the part this skill is + about. +- **Name the alternatives you rejected, and why.** A choice only reads as + researched if the reader can see what else was on the table. For any + category with a real competing option (a different pager, a different + version manager, a different plugin manager), name it and give the + concrete reason it lost — adoption gap, missing a feature the winner has, + official guidance pointing the other way, or "close call, no strong + reason, we just had to pick one." "We chose delta over diff-so-fancy + because delta has ~1.75x the stars and comparison writeups consistently + call it more capable" is a real comparison; "we chose delta" alone is + not, even with a star count attached. If the runner-up is close enough + that a reasonable person could disagree, say that too — don't manufacture + a bigger gap than the research actually found. +- **Cite something checkable.** Star counts, a specific doc URL, a specific + measurement from this project's own history. Avoid unsourced claims like + "widely considered best practice." +- **Be honest about confidence level, explicitly.** Say outright when a + value is "genuinely well-documented consensus" versus "a reasonable + choice, no strong consensus found" versus "this is now questionable and + here's why" (see `engine/modules/corepack.go` and + `engine/modules/direnv.go` for real examples of the third case — a + default that research revealed is no longer clearly right, documented as + such rather than defended). +- **Flag values that will go stale.** Pinned versions (a specific language + version, not a rolling "lts"/"stable" channel) need periodic revisiting — + say so in the comment so a future reader knows not to treat the number as + permanent (see `engine/modules/mise.go`'s python/go version handling). +- **Cross-reference, don't duplicate.** If three modules share one + underlying investigation (see the three `*_dedup.go` modules and their + shared measured-lag rationale), write the substantive explanation once + and have the others point to it, rather than copy-pasting. +- **It's fine to say "no strong consensus."** Not every value has a + research trail — say that plainly rather than inventing a justification. + A module that names its uncertainty is more trustworthy than one that + fabricates confidence. + +## Keeping rationale current: adversarial re-review + +Defaults don't stay correct forever — the ecosystem moves. When revisiting +an existing module (not just writing a new one), treat it as a live +question, not an inherited fact: + +- Ask "if I were choosing this tool for the first time today, with no + history, would I still pick it?" — not "can I justify what's already + there?" +- Actively look for reasons the original choice might now be wrong + (deprecation notices, a newer tool that's overtaken the incumbent, + official guidance that changed), not just evidence that supports keeping + it. +- If you find a default is now questionable, say so directly in the doc + comment (see corepack's unbundling, or direnv's discouraged mise + integration, both documented as open concerns rather than silently kept + or silently swapped) — devboost's config choices are meant to be + "opened up for anyone to challenge," per the project's own instructions, + which means surfacing doubt is more useful than hiding it. +- Log genuinely open questions (a default worth reconsidering but not yet + decided) as a tracked issue rather than either fixing it unilaterally or + letting the finding evaporate. + +## Cross-agent compatibility + +This file's real location is `.agents/skills/devboost-module-author/SKILL.md` +— the vendor-neutral convention (e.g. Codex CLI reads it there directly). +`.claude/skills` is a symlink to `.agents/skills`, so Claude Code and other +tools that read `.claude/skills/` (OpenCode, Cursor, Copilot) see the exact +same content with no separate copy to keep in sync. Edit only the +`.agents/skills/` original; any future skill just needs adding once, under +`.agents/skills/`. + +## Quick checklist for a new module + +- [ ] Researched adoption/reputation for the tool choice (checkable source) +- [ ] Checked the choice is still current, not just historically correct +- [ ] Checked the tool's own first-party docs for integration guidance + where relevant (not just third-party consensus) +- [ ] Doc comment above the constructor states why, with a source +- [ ] Confidence level stated honestly (consensus vs. taste vs. questionable) +- [ ] Pinned/version-specific values flagged as needing periodic review +- [ ] Shared rationale cross-referenced instead of duplicated +- [ ] `go build ./...` and `go test ./...` still pass diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.devboost.yaml.example b/.devboost.yaml.example index 0e1cc29..05c9406 100644 --- a/.devboost.yaml.example +++ b/.devboost.yaml.example @@ -1,12 +1,13 @@ # ~/.devboost.yaml # Example configuration file for devboost # Copy this to ~/.devboost.yaml and customize as needed +# Every key below has a default — this file is optional, and every +# key you omit just falls back to what's shown here. system: # auto_install_plugins: true installs TPM plugins immediately via CLI after writing tmux config. # Set to false if you prefer to run prefix+I manually inside a tmux session. auto_install_plugins: true - package_manager: auto # auto|brew|apt|dnf|pacman # Editor/terminal integration: configure how your terminal emulator and code editor # attach to the named tmux session so windows persist across restarts. @@ -49,13 +50,9 @@ packages: - dust - duf - procs - optional: - - btop - - httpie zsh: enable: true - plugin_manager: znap # fixed znap_git: https://github.com/marlonrichert/zsh-snap.git znap_path: ~/.zsh-snap include_file: ~/.zshrc.devboost # devboost-managed; sourced from user's ~/.zshrc @@ -65,8 +62,12 @@ zsh: # Filter mode controls how command history is shared between shells # Options: global (all shells), host (same machine), session (isolated), # directory (same folder), workspace (same git repo) - # Default: directory - context-specific history preferred by developers - filter_mode: directory + # Default: global for interactive ctrl-r search (matches atuin's own + # upstream default). filter_mode_shell_up_key_binding below separately + # scopes just the up-arrow key to directory, for quick context-local + # recall without restricting ctrl-r search. + filter_mode: global + filter_mode_shell_up_key_binding: directory fzf: enable: true default_command_files: "fd --type f --hidden --follow --exclude .git" @@ -74,6 +75,19 @@ zsh: aliases: enable: true +# Detects and disables shell tooling that duplicates what devboost already +# manages when found in your pre-existing ~/.zshrc/~/.zprofile (a leftover +# zinit setup duplicating znap's plugins, asdf alongside mise, nvm +# alongside mise, or oh-my-zsh itself alongside znap/starship). Redundant +# lines are commented out (never deleted); oh-my-zsh is migrated away from +# with your post-install customizations recovered into .zshrc. Run +# `devboost undo` to reverse any of this, or `devboost clean` to +# permanently remove the disabled lines instead. +optimize: + enable: true + zshrc: ~/.zshrc + zprofile: ~/.zprofile + prompt: enable_starship: true starship_config: ~/.config/starship.toml @@ -83,11 +97,10 @@ tmux: tpm_path: ~/.tmux/plugins/tpm conf_file: ~/.tmux.conf plugins: - - tmux-plugins/tpm - - tmux-plugins/tmux-resurrect - - tmux-plugins/tmux-continuum - - tmux-plugins/tmux-yank - - tmux-plugins/tmux-logging + logging: + # tmux-logging has noticeably lower adoption than the other bundled + # plugins (resurrect/continuum/yank) — opt-in rather than default-on. + enable: false settings: base_index: 1 pane_base_index: 1 @@ -107,11 +120,17 @@ toolchains: rust: "stable" deno: "lts" +# direnv is installed by default for plain per-directory env vars +# (arbitrary export lines in a project's .envrc). Toolchain activation is +# handled globally by `mise activate zsh` instead (see toolchains above) — +# mise's own docs describe this split as the current supported way to run +# both tools together, not a workaround. direnv.content is empty by +# default (nothing for devboost to manage); set it if you want devboost +# to own a specific ~/.direnvrc. direnv: enable: true rc_path: ~/.direnvrc - content: | - use_mise() { eval "$(mise activate direnv)"; } + content: "" git: delta: @@ -128,4 +147,3 @@ security: # and toolchains. The doctor command warns when toolchains are set to 'latest' or when # the Homebrew index is stale. Nothing auto-updates — you stay in control. enable: true - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index c7308e3..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Test - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test-linux: - name: Test on ${{ matrix.distro }} - runs-on: ubuntu-latest - strategy: - matrix: - distro: [ubuntu, debian, fedora, arch] - - steps: - - uses: actions/checkout@v4 - - - name: Build devboost.sh - run: ./build.sh - - - name: Test on ${{ matrix.distro }} - run: ./tests/test-linux.sh ${{ matrix.distro }} - - test-macos: - name: Test on macOS - runs-on: macos-latest - - steps: - - uses: actions/checkout@v4 - - - name: Build devboost.sh - run: ./build.sh - - - name: Test on macOS - run: ./tests/test-macos.sh - diff --git a/AGENTS.md b/AGENTS.md index 587f499..c423c8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - Development Guide for AI Agents and Contributors -This document provides guidelines for AI agents and human contributors working on devboost. The goal is to maintain **super high quality** code that follows 2025 best practices, prioritizes **ease of use**, and makes it **trivially easy** to add new modules. +This document provides guidelines for AI agents and human contributors working on devboost. The goal is to maintain **super high quality** code that follows Go and 2026 best practices, prioritizes **ease of use**, and makes it **trivially easy** to add new modules. ## Core Principles @@ -8,108 +8,84 @@ This document provides guidelines for AI agents and human contributors working o - **Zero prompts**: Users should never be asked questions during execution. All decisions should be made automatically with sensible defaults. - **Fully configurable**: Everything must be configurable via `~/.devboost.yaml`, but defaults should work perfectly out of the box. -- **Idempotent**: Safe to run multiple times. Always check state before making changes. -- **Non-destructive**: Never modify user's existing files directly. Use managed blocks/includes. +- **Idempotent**: Safe to run multiple times. This is structural, not per-module discipline — a resource's `Diff()` returning `nil` once converged *is* "nothing to do," not something each module has to remember to check. +- **Non-destructive**: Never modify user's existing files directly. Use managed blocks/includes and back up before any mutating write. -### 2. Code Quality Standards (2025) +### 2. Code Quality Standards -- **Bash best practices**: - - Use `set -euo pipefail` at the top of all scripts - - Quote all variables: `"$var"` not `$var` - - Use `[[ ]]` for conditionals (not `[ ]`) - - Prefer `command -v` over `which` - - Use `readonly` for constants - - Avoid `eval` unless absolutely necessary (and document why) +- **Go idioms**: + - Prefer the standard library over reinventing helpers (`filepath.Base`, not a hand-rolled equivalent) + - Wrap errors with context: `fmt.Errorf("resource %s: %w", id, err)` + - `go vet ./...` and `gofmt` must pass cleanly + - Keep functions focused (single responsibility) - **Error handling**: - - Always check return codes - - Provide helpful error messages with context - - Use `db_log_error` for errors, `db_log_warn` for warnings - - Never silently fail (unless explicitly handling expected failures) - -- **Performance**: - - Minimize external command calls - - Cache results when appropriate (OS detection, config parsing) - - Use efficient string operations - - Avoid unnecessary subshells + - Always check and propagate errors + - Provide helpful error messages with context — an unregistered `CommandGuarded` ID fails loudly, never a silent no-op + - Never silently fail (unless explicitly handling an expected, documented case — e.g. a file that doesn't exist yet) - **Readability**: - - Clear function names: `db_module_foo_apply` not `apply_foo` - - Consistent naming: `db_*` prefix for all functions - - Comments explain *why*, not *what* - - Keep functions focused (single responsibility) + - Clear function names (`Corepack`, not `applyCorepack`) + - Comments explain *why*, not *what* — well-named identifiers already say what + - Since most of this code is read (and often written) by both humans and coding agents, clarity beats cleverness ### 3. Module Development -**Adding a new module should be trivial:** - -1. Create `modules/module_foo.sh`: - -```bash -# Foo module - -db_module_foo_register() { - db_register_module "foo" \ - "db_module_foo_plan" \ - "db_module_foo_apply" \ - "db_module_foo_doctor" # optional -} - -db_module_foo_plan() { - local enable=$(db_yaml_get '.foo.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - # Check what would change - if [[ ! -f "$HOME/.foo/config" ]]; then - db_log_info "Would create: $HOME/.foo/config" - fi -} - -db_module_foo_apply() { - local enable=$(db_yaml_get '.foo.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - # Do the work idempotently - db_ensure_dir "$HOME/.foo" - local content=$(db_render_foo_config) - db_write_file "$HOME/.foo/config" "$content" -} - -# Optional: diagnostics -db_module_foo_doctor() { - db_command_exists foo && db_log_success "foo: found" || db_log_error "foo: not found" +**Adding a new module should be trivial.** A module is a plain function +returning a list of typed `engine.Resource` values — see +[ARCHITECTURE.md](ARCHITECTURE.md) for the full resource model +(`ResourceKind`, `PendingOp`, `ComputeDiff`, `DependsOn`). + +1. Create `engine/modules/foo.go`: + +```go +package modules + +import ( + "github.com/rolfsormo/devboost/config" + "github.com/rolfsormo/devboost/engine" + "github.com/rolfsormo/devboost/engine/kinds" +) + +// Foo ports , +// gated on foo.enable. +// +// +func Foo(cfg *config.Config) []engine.Resource { + if cfg.Get("foo.enable", "true") != "true" { + return nil + } + path := cfg.Get("foo.path", "~/.foorc") + return []engine.Resource{ + {ID: "foo_config", Kind: kinds.File{Path: path, Content: renderFooConfig(cfg)}}, + } } ``` -2. Add to `build.sh`: +2. Add it to `engine/modules/registry.go`'s `All` slice: -```bash -cat modules/module_foo.sh -echo "" -# ... in registration section: -db_module_foo_register +```go +{Name: "foo", Resources: func(cfg *config.Config, os kinds.OS) []engine.Resource { return Foo(cfg) }}, ``` -3. Rebuild: `./build.sh` +3. Write `foo_test.go` covering the enable-gate and the default resource shape. + +4. `go build ./... && go test ./...` -**That's it!** The module is now part of the system. +**That's it!** The module is now part of `plan`, `apply`, and `doctor` — with no separate plan/apply logic to keep in sync, since `engine.ComputeDiff` is the only place "what needs to change" is computed. ### Module Best Practices -- **Always check enable flag**: `local enable=$(db_yaml_get '.module.enable' 'true')` -- **Use core helpers**: `db_write_file`, `db_upsert_block`, `db_backup_file`, `db_ensure_dir` -- **Respect dry-run**: Check `DB_DRY_RUN` before making changes +- **Always check the enable flag first**: `if cfg.Get("module.enable", "true") != "true" { return nil }` +- **Prefer an existing kind** (`engine/kinds/`) over writing new diff/apply logic — `File`, `BlockInFile`, `LineInFile`, `GitConfig`, `Package`, `DirExists`, `GitClone` cover most cases +- **`CommandGuarded` is the deliberate escape hatch, not a shortcut** — use it only when no typed kind fits, and only by registering a real Go implementation via `kinds.RegisterCommand` (see `engine/kinds/commandguarded.go`). A module still only ever declares data (`{ID, Params, Wants}`) at the call site, never imperative logic. - **Provide defaults**: All config values should have sensible defaults - - **Research before defaulting**: Before choosing a default value, always research developer community preferences and best practices on the internet - - **Justify defaults**: Defaults should be chosen based on what works best for developers, not just what the tool's default is - - **Document reasoning**: When a default differs from the tool's default, document why in code comments -- **Idempotent operations**: Check if something exists before creating it -- **Use config system**: `db_yaml_get` for all configuration -- **Log appropriately**: Use `db_log_info`, `db_log_success`, `db_log_warn`, `db_log_error` + - **Research before defaulting**: Before choosing a default value, always research developer community preferences and best practices — check adoption/reputation (GitHub stars, maintenance activity), prefer a tool's own first-party docs over general blog consensus when they conflict, and check whether the choice is still current (tools get deprecated/unbundled/superseded — verify, don't assume) + - **Justify defaults**: Defaults should be chosen based on what works best for developers, not just what the tool's own default is + - **Document reasoning, honestly**: Write the rationale as a doc comment directly above the module's constructor, and say plainly whether it's well-documented consensus, a reasonable-but-undocumented preference, or (if research reveals it) now questionable — see `.agents/skills/devboost-module-author/SKILL.md` for the full process, and `engine/modules/corepack.go`/`engine/modules/direnv.go` for real examples of a default that got fixed, not just caveated, once research showed it should change +- **Idempotent operations**: a resource kind's `Diff()` must return `nil` once system state matches desired state — this is what makes `apply` safe to re-run, not a per-module convention to remember +- **Explicit dependencies**: declare `DependsOn` whenever one resource's correctness depends on another having already run (e.g. two resources writing to the same file) — never rely on registration order ### 4. Commit Message Style (CBEAMS) @@ -127,7 +103,7 @@ Follow the [CBEAMS commit message style](https://chris.beams.io/posts/git-commit - `feat`: New feature - `fix`: Bug fix - `docs`: Documentation only -- `style`: Formatting, missing semicolons, etc. +- `style`: Formatting, no code meaning change - `refactor`: Code change that neither fixes a bug nor adds a feature - `perf`: Performance improvement - `test`: Adding or updating tests @@ -136,30 +112,29 @@ Follow the [CBEAMS commit message style](https://chris.beams.io/posts/git-commit **Examples:** ``` -feat(zsh): add support for custom znap path +feat(mise): add support for a custom deno version pin -Allow users to configure znap installation path via -.zsh.znap_path in config file. Defaults to ~/.zsh-snap -if not specified. +Allow toolchains.globals.deno to be set to a specific version +instead of only "lts". Defaults to "lts" if not specified. Closes #42 ``` ``` -fix(starship): correct git_status format syntax +fix(git): correct delta.line-numbers config key -The format string was using invalid variable concatenation. -Changed to use $all_status only, which is the correct -starship syntax. +The key was being read as git.delta.lineNumbers, which never +matched a real config key — changed to git.delta.line_numbers +to match the documented example. Fixes #38 ``` ``` -docs(readme): add installation instructions +docs(readme): add build-from-source instructions -Adds quick start section with curl-based installation -and build-from-source instructions. +Adds a Quick Start section for building the binary directly, +since no release binaries are published yet. ``` **Rules:** @@ -173,7 +148,7 @@ and build-from-source instructions. ### 5. Versioning Strategy -devboost follows **Semantic Versioning** with OS/tooling-specific adjustments: +devboost follows **Semantic Versioning**: **Format:** `MAJOR.MINOR.PATCH` @@ -199,31 +174,13 @@ devboost follows **Semantic Versioning** with OS/tooling-specific adjustments: - Users can always upgrade PATCH versions safely - Users can upgrade MINOR versions safely (new features available) -- Users upgrading MAJOR versions should review changelog for breaking changes -- The script should detect if user's config is from an older MAJOR version and warn (but not block) - -**Version Detection:** - -```bash -# In core_main.sh or similar -DB_VERSION="1.0.0" -DB_CONFIG_VERSION=$(db_yaml_get '.version' '') - -if [[ -n "$DB_CONFIG_VERSION" ]]; then - local config_major=$(echo "$DB_CONFIG_VERSION" | cut -d. -f1) - local script_major=$(echo "$DB_VERSION" | cut -d. -f1) - - if [[ "$config_major" -lt "$script_major" ]]; then - db_log_warn "Config file version ($DB_CONFIG_VERSION) is older than script version ($DB_VERSION)" - db_log_warn "Please review CHANGELOG.md for breaking changes" - fi -fi -``` +- Users upgrading MAJOR versions should review [CHANGELOG.md](CHANGELOG.md) for breaking changes +- **Not yet implemented**: automatic in-tool detection/warning that a user's config predates the current MAJOR version (the earlier bash implementation had this; it hasn't been ported — see [ARCHITECTURE.md](ARCHITECTURE.md#future-enhancements)). Until it exists, check the changelog manually before a MAJOR upgrade. **Versioning Best Practices:** -- Update version in `core/core_main.sh` (`DB_VERSION`) -- Tag releases: `git tag -a v1.0.0 -m "Release 1.0.0"` +- Update `version` in `cmd/devboost/main.go` +- Tag releases: `git tag -a v2.0.0 -m "Release 2.0.0"` - Maintain `CHANGELOG.md` with: - Breaking changes (MAJOR) - New features (MINOR) @@ -232,31 +189,31 @@ fi **REQUIRED Before Pushing to origin/main:** 1. **Bump version** if needed (MAJOR for breaking changes, MINOR for new features, PATCH for fixes) -2. **Build the script**: `./build.sh` must succeed (builds to `devboost.sh` in root) -3. **Verify build**: `bash -n devboost.sh` must pass -4. **Commit both**: version bump in `core/core_main.sh` AND the built `devboost.sh` in root +2. **Build**: `go build ./...` must succeed +3. **Vet**: `go vet ./...` must pass +4. **Test**: `go test ./...` must pass (this includes the slow real end-to-end apply test — don't skip it before a push, only during rapid local iteration) +5. **Commit**: the version bump in `cmd/devboost/main.go` **Version Bump Guidelines:** -- **PATCH** (1.0.0 → 1.0.1): Bug fixes, documentation updates, internal improvements -- **MINOR** (1.0.0 → 1.1.0): New features, new modules, new config options (backwards compatible) -- **MAJOR** (1.0.0 → 2.0.0): Breaking changes, config schema changes, removed features +- **PATCH** (2.0.0 → 2.0.1): Bug fixes, documentation updates, internal improvements +- **MINOR** (2.0.0 → 2.1.0): New features, new modules, new config options (backwards compatible) +- **MAJOR** (2.0.0 → 3.0.0): Breaking changes, config schema changes, removed features **Never push to main without:** - ✅ Version bumped (if changes warrant it) -- ✅ Script built (`./build.sh` - builds to root `devboost.sh`) -- ✅ Build verified (`bash -n devboost.sh`) -- ✅ Built `devboost.sh` committed to repository -- ✅ All tests passing (including any new tests for the feature) -- ✅ Changes documented (README, CHANGELOG, or code comments as appropriate) +- ✅ `go build ./...` succeeds +- ✅ `go vet ./...` passes +- ✅ `go test ./...` passes (including any new tests for the feature) +- ✅ Changes documented (README, CHANGELOG, module rationale doc comment, or ARCHITECTURE.md as appropriate) **Complete Workflow for Changes:** 1. Create a feature branch: `git checkout -b feat/` 2. Make your changes in small, logical commits (see Commit Message Style) 3. Write/update tests for the changes -4. Run all tests and ensure they pass: `./tests/run-tests.sh` -5. Build and verify: `./build.sh && bash -n devboost.sh` -6. Update documentation (README, CHANGELOG, etc.) -7. Bump version if needed (in `core/core_main.sh`) and rebuild: `./build.sh` +4. Run all tests and ensure they pass: `go test ./...` +5. Build and verify: `go build ./... && go vet ./...` +6. Update documentation (README, CHANGELOG, module rationale, etc.) +7. Bump version if needed (in `cmd/devboost/main.go`) 8. Push the branch and open a PR against `main` 9. PRs must pass all checks before merging — never push directly to `main` @@ -272,32 +229,21 @@ fi Before submitting changes, you **must** run and pass all applicable tests: -1. **Build test**: `./build.sh` must succeed -2. **Syntax check**: `bash -n devboost.sh` must pass -3. **Plan test**: `./devboost.sh plan` should show expected changes -4. **Idempotency test**: Run `apply` twice, second run should be no-op -5. **Config test**: Test with minimal config and full config -6. **Platform tests**: - - **macOS**: `./tests/test-macos.sh` must pass - - **Linux**: `./tests/test-linux.sh all` must pass (if Docker is available) - - If you can't test on a platform, note it in your PR and ask for help +1. **Build**: `go build ./...` must succeed +2. **Vet**: `go vet ./...` must pass +3. **Unit tests**: `go test ./... -short` must pass (fast — skips the slow real end-to-end apply) +4. **Full tests**: `go test ./...` must pass before pushing (includes `TestSandboxedApplyPlanDoctorIdempotent`, which builds the real binary and runs `plan`/`apply --dry-run`/`doctor`/`apply` against real tools in a sandboxed `HOME` — slow, touches real Homebrew/git, but is what actually catches "the pieces work in isolation but not wired together") +5. **Plan test**: `go run ./cmd/devboost plan` should show expected changes +6. **Idempotency test**: run `apply` twice against a fresh temp `HOME` — the second run should make no further `.zshrc`/config changes +7. **Config test**: test with a minimal config and a full config (see `.devboost.yaml.example`) +8. **Platform tests**: the full Go test suite already covers macOS/Linux where the code is platform-dependent (see `runtime.GOOS` checks in the relevant tests) — if you can't test on a platform locally, note it in your PR **Test Execution:** ```bash -# Build first -./build.sh - -# Test on macOS (sandboxed, safe) -./tests/test-macos.sh - -# Test on all Linux distributions (requires Docker) -./tests/test-linux.sh all - -# Or test individual distributions -./tests/test-linux.sh ubuntu -./tests/test-linux.sh debian -./tests/test-linux.sh fedora -./tests/test-linux.sh arch +go build ./... +go vet ./... +go test ./... -short # fast iteration +go test ./... # full, including the slow end-to-end test — required before push ``` **Failure is not an option**: If tests fail, the change is not complete. Fix the issues or document why the failure is acceptable (with maintainer approval). @@ -305,45 +251,44 @@ Before submitting changes, you **must** run and pass all applicable tests: ### 7. Documentation Requirements - **Code comments**: Explain *why*, not *what* -- **Function docs**: Brief comment above each exported function -- **Config docs**: Document all config options in `.devboost.yaml.example` +- **Module rationale**: Every module that picks a specific tool documents why, directly above its constructor function — see `.agents/skills/devboost-module-author/SKILL.md` +- **Config docs**: Document all config options in `.devboost.yaml.example`, and verify it actually loads (`config.Load(".devboost.yaml.example")`) rather than trusting it by inspection - **README**: Keep up to date with new features - **CHANGELOG**: Document all user-facing changes +- **ARCHITECTURE.md**: Keep up to date if the engine or module structure changes ### 8. Security Considerations -- **Never execute user input**: All user input should be validated -- **Use absolute paths**: When possible, use absolute paths for security -- **Sanitize paths**: Validate file paths before operations -- **Backup before modify**: Always backup files before modifying -- **Principle of least privilege**: Don't require sudo unless necessary +- **Never execute unsanitized user input** +- **Use absolute paths** where possible +- **Sanitize/validate file paths** before operations +- **Backup before modify**: always back up a file before overwriting it (see `kinds.BackupFile`) +- **Principle of least privilege**: don't require sudo unless necessary +- **Never fabricate credential-fetching commands against real hosts during testing** — if a keychain/credential-helper interaction needs to be tested, use a dummy host, never a real one ### 9. Cross-Platform Compatibility -- **OS detection**: Use `db_detect_os` and check `DB_OS` -- **Package managers**: Use `db_install_packages` abstraction -- **Path differences**: Handle macOS (`/opt/homebrew`) vs Linux paths -- **Test on both**: When possible, test on macOS and Linux +- **OS detection**: `kinds.DetectOS()` returns a typed `kinds.OS` (`OSDarwin`, `OSLinuxUbuntu`, `OSLinuxFedora`, `OSLinuxArch`, `OSOther`) +- **Package managers**: `kinds.Package` abstracts brew/apt/dnf/pacman — add new package-name mappings there, not in individual modules +- **Path differences**: handle macOS (`/opt/homebrew`) vs Linux paths explicitly where it matters (e.g. tmux integration docs) +- **Test on both**: when possible, test on macOS and Linux locally before pushing — no GitHub Actions CI runs automatically (deliberate: local development cost is effectively free, GitHub Actions spend is not, so it stays opt-in rather than running on every push) ### 10. Performance Guidelines -- **Minimize external calls**: Cache results when appropriate -- **Batch operations**: Group similar operations together -- **Lazy evaluation**: Only do work when needed -- **Efficient checks**: Use `command -v` not `which`, `test -f` not `ls` +- **Minimize external calls**: cache results when appropriate (e.g. `sync.Once` for a one-time `apt-get update`, not concurrency — just deduplication) +- **Batch where the engine already supports it**: `ComputeDiff` computes the whole graph once for `plan`/`doctor`; `apply`'s `DiffAndExecute` still runs one resource at a time in dependency order, which is required (a dependent resource must see real post-execution state), not accidental overhead ## Quick Reference ### Adding a Module Checklist -- [ ] Create `modules/module_foo.sh` -- [ ] Implement `db_module_foo_register()` -- [ ] Implement `db_module_foo_plan()` (check enable flag) -- [ ] Implement `db_module_foo_apply()` (idempotent, use helpers) -- [ ] Add to `build.sh` (file inclusion + registration) +- [ ] Create `engine/modules/foo.go` +- [ ] Implement `Foo(cfg *config.Config) []engine.Resource` (check the enable flag first) +- [ ] Research the tool choice; write the rationale as a doc comment above `Foo` +- [ ] Register in `engine/modules/registry.go`'s `All` slice +- [ ] Write `foo_test.go` (enable-gate, default resource shape, any `DependsOn` interactions) - [ ] Add config options to `.devboost.yaml.example` -- [ ] Test: `./build.sh && ./devboost.sh plan` -- [ ] Test: `./devboost.sh apply` (idempotent) +- [ ] `go build ./... && go test ./...` - [ ] Update README if user-facing - [ ] Update CHANGELOG - [ ] Commit with CBEAMS style @@ -351,49 +296,46 @@ Before submitting changes, you **must** run and pass all applicable tests: ### Common Patterns **Check if enabled:** -```bash -local enable=$(db_yaml_get '.module.enable' 'true') -if [[ "$enable" != "true" ]]; then - return 0 -fi +```go +if cfg.Get("module.enable", "true") != "true" { + return nil +} ``` -**Write file with backup:** -```bash -local content="..." -db_write_file "$HOME/.file" "$content" +**Write a fully-managed file (with automatic backup before overwrite):** +```go +{ID: "foo_config", Kind: kinds.File{Path: path, Content: content}} ``` -**Inject block into existing file:** -```bash -local block="..." -db_upsert_block "$HOME/.file" "# start marker" "# end marker" "$block" +**Inject/update a block into an existing (user-owned) file:** +```go +{ID: "foo_block", Kind: kinds.BlockInFile{Path: path, StartMarker: startMarker, EndMarker: endMarker, Content: block}} ``` -**Check command exists:** -```bash -if ! db_command_exists tool; then - db_log_warn "tool not found, skipping" - return 0 -fi +**Declare a dependency on another resource:** +```go +{ID: "foo_block", Kind: kinds.BlockInFile{...}, DependsOn: []string{"other_resource_id"}} ``` -**Respect dry-run:** -```bash -if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would do X" - return 0 -fi -# Do actual work +**Escape hatch for state no typed kind covers** (register the real implementation once, in an `init()`, in the file that owns the use case): +```go +func init() { + kinds.RegisterCommand("foo_converged", kinds.GuardedCommand{ + Satisfied: func(params any) (bool, error) { /* ... */ }, + Converge: func(params any) error { /* ... */ }, + }) +} +// declaration site stays pure data: +{ID: "foo", Kind: kinds.CommandGuarded{ID: "foo_converged", Wants: "foo converged"}} ``` ## Questions? If you're unsure about implementation details: -1. Check existing modules for patterns -2. Review core framework functions in `core/` -3. Test your changes thoroughly -4. Ask for review if needed +1. Check existing modules for patterns (`engine/modules/*.go`) +2. Review [ARCHITECTURE.md](ARCHITECTURE.md) for the engine/resource model +3. Check `.agents/skills/devboost-module-author/SKILL.md` for the research-and-document process +4. Test your changes thoroughly +5. Ask for review if needed Remember: **Ease of use > Performance > Cleverness** - diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 040b779..b322e03 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,176 +2,245 @@ ## Overview -devboost is built as a **modular Bash framework** that concatenates into a single distributable script. This design provides: - -- **Single-command UX**: One `devboost.sh` file to distribute -- **Easy extensibility**: Add modules by creating new files -- **Maintainability**: Clear separation of concerns -- **Zero runtime dependencies**: Pure Bash + `yq` (or Python fallback) +devboost is a Go program built as a **Terraform-inspired typed-resource +engine**: every module describes desired state as a list of typed +`engine.Resource` values, and one shared function (`engine.ComputeDiff`) +computes what's out of sync. `plan` and `apply` both call that same +function — `plan` prints the result and stops, `apply` prints and then +executes it — so there is no separate hand-written plan narration to +drift out of sync with what apply actually does. + +This replaced an earlier bash implementation (still visible in git +history) where every module hand-wrote a `plan` function and an `apply` +function separately, describing the same change twice in two different +places. That duplication was a real, hit-in-production bug class — see +the [CHANGELOG](CHANGELOG.md) for the double-sourcing bug this surfaced. +`ComputeDiff` closes that bug class structurally: there is only one place +"what needs to change" is computed, for both commands. ## Directory Structure ``` devboost/ - devboost.sh.in # Entry point (minimal) - build.sh # Build script (concatenates everything) - - core/ # Framework components - core_main.sh # CLI, argument parsing, main execution - core_log.sh # Logging functions (db_log_*) - core_os.sh # OS detection, package manager abstraction - core_yaml.sh # YAML config parsing (via yq) - core_files.sh # File operations (backup, write, block management) - core_omz.sh # oh-my-zsh migration helper (git merge-file based) - core_modules.sh # Module registry system - - modules/ # Feature modules - module_pkg.sh # Package installation - module_znap.sh # Znap plugin manager - module_zsh.sh # Zsh configuration - module_starship.sh # Starship prompt - module_tmux.sh # Tmux configuration - module_mise.sh # Mise toolchains - module_direnv.sh # Direnv setup - module_git.sh # Git delta config - module_services.sh # Service management (atuin, etc.) - - templates/ # Template files (future use) - dist/ # Build output (gitignored) + cmd/devboost/ # CLI entry point (main.go) + + engine/ # Core engine — no knowledge of specific modules + resource.go # Resource, ResourceKind, PendingOp, ComputeDiff, DiffAndExecute + plan.go # Plan(): diff, print, stop + apply.go # Apply(): diff-and-execute one resource at a time, in dependency order + doctor.go # Doctor(): diffs the combined graph once, groups results by module + diagnostic.go # Diagnostic/DiagnosticFunc: read-only findings with nothing to converge + + kinds/ # Resource kind implementations — the "providers" + directory.go # DirExists + gitclone.go # GitClone + file.go # File (full-content, backed up before overwrite) + gitconfig.go # GitConfig (shells out to `git config`) + blockinfile.go # BlockInFile / RemoveBlock (managed block between markers) + lineinfile.go # LineInFile (the dedup modules' comment-out mechanism) + package.go # Package (per-OS package manager abstraction) + commandguarded.go # CommandGuarded (the one deliberate escape hatch) + backup.go # Shared backup/snapshot/archive helpers + os.go # OS detection + + modules/ # Module ports — the actual opinionated defaults + registry.go # Module registry: All, AllResources + znap.go, starship.go, tmux.go, mise.go, pkg.go, git.go, corepack.go, + direnv.go, services.go, security.go, zsh*.go, *_dedup.go, uninstall.go, + migrateohmyzsh.go, clean.go + + config/ # ~/.devboost.yaml reader (config.Config) ``` -## Module Interface - -Every module implements a simple interface: - -```bash -# modules/module_foo.sh - -db_module_foo_register() { - db_register_module "foo" \ - "db_module_foo_plan" \ - "db_module_foo_apply" \ - "db_module_foo_doctor" # optional -} +## The Resource Model -db_module_foo_plan() { - # Read config with db_yaml_get - # Output what would change - db_log_info "Would do X, Y, Z" +```go +// A resource kind knows how to diff itself against live system state. +type ResourceKind interface { + Diff() (*PendingOp, error) } -db_module_foo_apply() { - # Do the actual work, idempotently - # Use core helpers: db_write_file, db_upsert_block, etc. +// What a module declares: an ID, a kind, and optional dependencies. +type Resource struct { + ID string + Kind ResourceKind + DependsOn []string } -db_module_foo_doctor() { - # Optional: diagnostics for this module +// A pending change — never authored directly, always computed by Diff(). +type PendingOp struct { + ResourceID string + Description string + Execute func() error } ``` -## Core Framework - -### Module Registry - -The registry (`core_modules.sh`) maintains: - -- `DB_MODULE_NAMES`: Array of module names -- `DB_MODULE_PLAN_FUNC`: Map of name → plan function -- `DB_MODULE_APPLY_FUNC`: Map of name → apply function -- `DB_MODULE_DOCTOR_FUNC`: Map of name → doctor function (optional) - -Modules register themselves during `db_load_modules()`. - -### Config System - -Uses `yq` (preferred) or Python3 with PyYAML (fallback) to parse `~/.devboost.yaml`: - -```bash -# Get config value with default -local value=$(db_yaml_get '.zsh.enable' 'true') - -# Get list (space-separated) -local pkgs=$(db_yaml_get_list '.packages.base[]') -``` - -### File Operations - -Core file helpers ensure safety: - -- `db_write_file`: Writes file with backup -- `db_upsert_block`: Replaces block between markers in existing file -- `db_remove_block`: Removes block between markers -- `db_backup_file`: Creates timestamped backup - -### OS Abstraction +`ComputeDiff` topologically sorts resources by `DependsOn` and calls +`Diff()` on each once — used by `plan` and `doctor`, where nothing has +converged yet so a single batch pass is safe. `DiffAndExecute` interleaves +diff-then-immediately-execute per resource in dependency order — required +by `apply`, because a dependent resource must see the *real* post-execution +state of what it depends on, not a stale batch diff. -`core_os.sh` provides: - -- `db_detect_os`: Sets `DB_OS` (darwin, linux-ubuntu, linux-fedora, linux-arch) -- `db_install_packages`: Installs packages via appropriate package manager -- `db_command_exists`: Checks if command is available - -## Build Process - -`build.sh` concatenates files in order: - -1. Entry point (`devboost.sh.in`) -2. Core framework (in dependency order) -3. All modules -4. Module registration code -5. Main execution wrapper - -Result: Single `devboost.sh` file (~1280 lines) that's self-contained. - -## Adding a New Module - -1. Create `modules/module_neovim.sh`: - -```bash -db_module_neovim_register() { - db_register_module "neovim" \ - "db_module_neovim_plan" \ - "db_module_neovim_apply" -} +## Module Interface -db_module_neovim_plan() { - db_log_info "Would configure Neovim" +A module is just a plain Go function returning a resource list — no +interface to implement, no registration boilerplate beyond adding one +line to the registry: + +```go +// engine/modules/foo.go +package modules + +func Foo(cfg *config.Config) []engine.Resource { + if cfg.Get("foo.enable", "true") != "true" { + return nil + } + return []engine.Resource{ + {ID: "foo_config", Kind: kinds.File{Path: cfg.Get("foo.path", "~/.foorc"), Content: renderFooConfig(cfg)}}, + } } +``` -db_module_neovim_apply() { - local config_dir="$HOME/.config/nvim" - db_ensure_dir "$config_dir" - # ... configure neovim +```go +// engine/modules/registry.go +var All = []Module{ + // ... + {Name: "foo", Resources: func(cfg *config.Config, os kinds.OS) []engine.Resource { return Foo(cfg) }}, } ``` -2. Add registration call to `build.sh`: - -```bash -cat modules/module_neovim.sh -echo "" -# ... in registration section: -db_module_neovim_register -``` +That's it — `plan`, `apply`, and `doctor` all pick it up automatically +through `AllResources`/`Doctor`, with zero separate plan/apply logic to +keep in sync. + +## Why Go Struct Literals, Not YAML, for Resource Declarations + +Module resource declarations are plain Go struct literals — not a DSL, +not YAML. This was a deliberate choice made once Go was already settled +on as the implementation language (the *user-facing* `~/.devboost.yaml` +config file is unrelated and stays YAML — see `config/config.go`). The +reasoning: most of devboost's code will be read and modified by coding +agents more than by hand, so "not fluent in Go" carries little weight — +what matters is readability and correctness, and Go struct literals get +full compiler and type checking that a YAML-based DSL would need to +reinvent. See git history for the fuller discussion (Docker Compose was +considered as a counter-example favoring YAML, but ultimately devboost's +"a module should be trivial to add, optimized on boilerplate" goal was +better served by plain typed Go). + +## Resource Kinds ("Providers") + +Each kind in `engine/kinds/` is a small, reusable, parametrized primitive +— the equivalent of a Terraform provider resource type. Shelling out +inside a kind's `Diff`/apply is fine, and often correct, when the target +tool's own CLI is the best available diff/apply primitive (`GitConfig` +shells to `git config`, `Package` shells to `brew`/`apt`/`dnf`/`pacman`) — +never as a shortcut to skip writing a real diff. + +`CommandGuarded` is the one deliberate escape hatch, for state that +genuinely doesn't fit any typed kind (see `engine/kinds/commandguarded.go` +for the full reasoning). A module still only ever declares data — `{ID, +Params, Wants}` — never imperative logic at the declaration site. An +unregistered `ID` fails loudly (not a silent no-op): adding a new use +requires writing a real Go implementation in `kinds`, registered via +`RegisterCommand`, the same amount of real work as adding a proper kind. +This is intentional friction — it must never be the easy path when a +typed kind is achievable instead. + +`VendorInstall` and `GitHubReleaseInstall` handle tools with no package +on a given platform at all (confirmed by directly querying real +apt/dnf, not assumed: lazygit, mise, atuin, starship, dust, and procs +aren't packaged on stock Debian/Ubuntu or Fedora, varying by distro). +`VendorInstall` fetches and runs a tool's own official non-interactive +install script; `GitHubReleaseInstall` resolves and extracts a project's +latest GitHub release binary when no install script exists at all. Both +install into `kinds.ManagedBinDir` (`~/.local/bin`), forced explicitly +via each installer's own destination env var — several default +elsewhere otherwise (atuin: `~/.atuin/bin`; starship/dust: +`/usr/local/bin`) — and both are checked for presence via +`kinds.BinaryAvailable`, which — unlike a bare `exec.LookPath` — checks +`ManagedBinDir` directly, not just the current process's `PATH` (which +never includes it; only a future shell does, via the zsh module's own +rendered `PATH` export). Any kind or module invoking one of these tools +directly must resolve its real path via `kinds.ResolveBinary` for the +same reason — a literal `exec.Command("mise", ...)` from inside +devboost's own process silently fails "not found" even moments after +mise was genuinely installed in the same `apply` run, confirmed via a +real Ubuntu container test. + +Checks like `BinaryAvailable`/`ResolveBinary` are deliberately +evaluated fresh every call (inside a kind's `Diff`/`Satisfied`/ +`Converge`), never decided once when a module's resource list is built +— a module-construction-time-only check can never see a tool a +*different* resource installs earlier in the same `apply` run. This was +a real bug (`Mise()`/`Services()` both gated themselves off entirely at +construction time), not just a style preference: if a check like this +ever becomes expensive enough to need memoizing, that's an explicit +opt-in at the call site, not something baked into the calling +convention by default. + +## Dependencies + +Resources declare explicit `DependsOn` when one resource's correctness +depends on another having already run — e.g. `security`'s managed block +depends on `zsh`'s full-file render, because writing the block first and +then having zsh's `File` resource overwrite the whole file would silently +destroy it. `engine.ComputeDiff`/`DiffAndExecute` topologically sort on +this, with cycle and unknown-dependency detection. This replaced the bash +version's implicit, hand-maintained module registration order. + +## doctor: Tool-First Grouping + +`doctor` diffs every module's combined resource graph in one pass (not +per-module in isolation — an earlier version did that and broke the +moment a cross-module `DependsOn` was added, see the module's tests for +the regression coverage), then groups the results back by owning module +for display. This keeps output readable as dedup checks and diagnostics +accumulate — ten tools with several checks each still reads as ten +grouped sections, not a flat list of every individual check. + +## Module Rationale Documentation + +Every module that picks a specific tool documents *why* directly above +its constructor function — adoption/reputation research, first-party +guidance where it conflicts with common practice, and an honest +confidence level (well-documented consensus vs. taste vs. now-questionable). +See `.agents/skills/devboost-module-author/SKILL.md` for the process, and +any module file (e.g. `engine/modules/git.go`, `engine/modules/corepack.go`) +for real examples — including examples of a default that research +revealed was worth actually fixing, not just caveat-ing. -3. Rebuild: `./build.sh` +## Adding a New Module -That's it! The module is now part of the system. +1. Create `engine/modules/foo.go` with a `Foo(cfg *config.Config) []engine.Resource` function. +2. Research the tool choice (see the module-author skill) and write the rationale as a doc comment above `Foo`. +3. Add a test file `foo_test.go` covering the enable-gate and default-resource-shape. +4. Register it in `engine/modules/registry.go`'s `All` slice. +5. Run `go build ./... && go test ./...`. +6. Add any new config keys to `.devboost.yaml.example`. ## Design Principles -1. **Idempotency**: All operations are safe to re-run -2. **Non-destructive**: Never overwrite user files directly -3. **Config-driven**: Defaults in code, overrides in YAML -4. **Modular**: Each module is independent -5. **Extensible**: Adding features = adding modules +1. **Idempotency**: structural, not per-module discipline — `ComputeDiff` returning no pending ops *is* "nothing to do." +2. **Non-destructive**: never overwrite user files directly — managed blocks/includes, backups before every mutating write. +3. **Config-driven**: sensible defaults in code, overrides in `~/.devboost.yaml`. +4. **Explicit dependencies**: `DependsOn`, not registration order, decides execution order. +5. **One diff function**: `plan` and `apply` can never narrate a different change than what actually happens. -## Future Enhancements +## Bootstrap Distribution -- Template system for complex configs -- Module dependencies (e.g., zsh depends on znap) -- Parallel module execution -- Better diff/plan output -- Module-specific uninstall hooks +`install.sh` (repo root) is a small, pure-POSIX-shell dispatcher modeled +on rustup's `rustup-init.sh`: detect OS/arch (including the Rosetta 2 +edge case on Apple Silicon), download the matching prebuilt binary from +the [latest release](https://github.com/rolfsormo/devboost/releases/latest), +exec it. No application logic lives there — everything real is in the Go +binary. Cross-compilation is currently local-only (`GOOS`/`GOARCH` builds +run by hand for each release, no GitHub Actions release pipeline yet). + +## Future Enhancements +- Automate cross-platform release builds (currently cut by hand — see Bootstrap Distribution above). +- In-tool config-schema-version warning (bash version had this; not yet ported — see [CHANGELOG.md](CHANGELOG.md)). +- Async/prefetched `apt-get update` once the CLI orchestrates multiple resources concurrently. +- Periodic adversarial re-review of each module's tool choice against current ecosystem state (see the module-author skill). diff --git a/CHANGELOG.md b/CHANGELOG.md index be0844a..f0c7adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,65 @@ with OS/tooling-specific adjustments. ## [Unreleased] +### Changed +- **`legacy_shell` config key renamed to `optimize`.** "Legacy" never said what it was legacy *compared to*; `optimize` matches how the feature is actually described (and the README's own "Startup Optimizations" section). `legacy_shell.enable`/`.zshrc`/`.zprofile` become `optimize.enable`/`.zshrc`/`.zprofile`. `kinds.MarkerPrefix` (`# devboost:disabled:...`) is unchanged — it's a stable on-disk format independent of the config key name. +- **oh-my-zsh migration is now a normal `apply` resource, not a separate `--yes`-gated subcommand.** `migrate-from-oh-my-zsh` and `--yes` are removed. `omz_migration` joins the three existing dedup modules (zinit/asdf/nvm) as a fourth resource under `optimize.enable`, converged automatically by a plain `devboost apply` — no confirmation prompt, matching the zero-prompt behavior every other devboost resource already has. A confirm-then-rerun `--yes` gate is itself a soft prompt spread across two invocations; rustup's `-y`, Homebrew's `NONINTERACTIVE=1`, and oh-my-zsh's own installer's `--unattended` all confirm the real pattern is "ask only when run interactively by a human," not "skip and tell them to rerun with a flag." + +### Added +- **`devboost undo`**: reverses whatever `apply` actually converged, for any resource whose kind implements the new `engine.Undoer` interface — currently the four optimize resources. Restores commented-out lines to their exact original text, and for oh-my-zsh, moves the archived `~/.oh-my-zsh` back into place and restores `.zshrc` from its pre-migration backup. Consumed backups are renamed (`-reverted` suffix, kept on disk rather than deleted) so a repeated `undo` correctly reports nothing left to restore instead of re-restoring the same backup. Refuses to run (unless `--force`) when something it would restore has changed since it last converged — e.g. `~/.oh-my-zsh` was recreated by hand after a migration already ran — since its backups may no longer describe the current state accurately. +- **`--no-optimizations`**: a per-invocation shortcut for `optimize.enable: false`, via a new `config.Config.Set` (the write-side mirror of `Get`'s dotted-key lookup) — so it's indistinguishable from the user having written the equivalent config key. +- **`--force`**: lets `undo` proceed despite the drift-refusal check above. +- `kinds.CommandGuarded` gained optional `UndoConverge`/`UndoSatisfied` on `GuardedCommand`, and `kinds.LineInFile` gained `Undo()` — both implement the new `engine.Undoer` interface. + +## [2.0.0] - 2026-08-08 + +### Changed +- **Full rewrite from bash to Go.** Replaced the hand-written bash `plan`/`apply` function pairs per module (which had already drifted out of sync in production — see the `1.3.0` double-sourcing fix) with a Terraform-inspired typed-resource engine: modules declare desired state as `engine.Resource` values, and one shared function (`engine.ComputeDiff`) computes what's out of sync for both `plan` and `apply`. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full design. The CLI entry point is now a single Go binary (`devboost`) instead of a concatenated `devboost.sh`; `install.sh` is a new pure-POSIX-shell bootstrap dispatcher (rustup-style) that fetches and execs the binary. +- **All prior functionality ported**: `apply`, `plan`, `doctor` (now grouped tool-first by module), `uninstall`, `clean`, oh-my-zsh migration (later folded into `apply` itself — see `[Unreleased]`), and every module (znap, zsh, starship, tmux, mise, pkg, git/delta, corepack, direnv, services/atuin, security, and the zinit/asdf/nvm dedup modules). +- **direnv**: no longer writes a `use_mise` `.direnvrc` helper by default. mise's own docs call that integration pattern deprecated; `mise activate zsh` (already run globally) fully replaces it via mise's own directory-change hook. direnv stays installed for plain per-directory env vars; `direnv.content` still lets a user opt into managed `.direnvrc` content. +- **corepack**: now installs itself via `npm install -g corepack` when missing, instead of silently treating absence as "nothing to do." Node 25+ no longer bundles corepack (Node's own TSC decision), so absence is now the expected case on a current toolchain — this matches the exact replacement workflow Node's TSC decision names explicitly. + +### Added +- `devboost clean --dry-run` support (previously apply-only in the bash version's port). +- Every module that picks a specific tool now documents why, as a doc comment above its constructor — adoption/reputation research, first-party guidance, and an honest confidence level (well-documented consensus vs. taste vs. now-questionable). See `.agents/skills/devboost-module-author/SKILL.md` for the process used to write these. +- Explicit `DependsOn` on resources — a real dependency graph with topological sort and cycle detection, replacing the bash version's implicit, hand-maintained module registration order. +- **Real Debian/Ubuntu and Fedora support for tools apt/dnf don't package.** Found via the first genuine end-to-end `apply` run this project has ever done on fresh Linux (previously only `--dry-run` was tested): lazygit, mise, atuin, starship, dust, and procs (varies by distro) aren't packaged at all on stock apt/dnf — the bash tool had this exact same gap, silently. `kinds.VendorInstall` (fetch-and-run the tool's own official installer) and `kinds.GitHubReleaseInstall` (download+extract the latest GitHub release for tools with no installer script at all — lazygit, procs) now converge these into `~/.local/bin`, added to `PATH` by the zsh module's own rendered config. See `docs/` and `ARCHITECTURE.md`'s Resource Kinds section. + +### Fixed +- **A failed resource no longer aborts the whole `apply`.** `engine.DiffAndExecute` previously stopped the entire run on any resource's `Execute` error — found as a real, current-state blocker via the Linux testing above (one apt package genuinely unavailable silently prevented zsh/tmux/git config and everything else from converging). Failed resources and everything transitively depending on them are now skipped individually; everything else still converges. `Package.Execute` similarly no longer stops at the first failed package — it attempts all of them and reports every failure together. +- **`Mise()`/`Services()` no longer gate themselves off at module-construction time.** Both used to decide "does this resource even exist" via `exec.LookPath` before any resource had executed — meaning neither could ever see a tool a *different* resource installs earlier in the same `apply` run (e.g. mise's own toolchain-converge step silently never ran on a machine where mise itself needed installing first). Availability is now checked fresh inside each resource's own `Satisfied`/`Converge`. +- **Managed-tool invocations resolve their real path, not a bare command name.** devboost's own process `PATH` never includes `~/.local/bin` (only a future shell gets that, via the zsh module) — `exec.Command("mise", ...)` from inside devboost silently failed "not found" even moments after mise had genuinely just been installed. Fixed via `kinds.ResolveBinary`. +- `corepack` now explicitly `DependsOn`s `mise_toolchains` — it needs mise's Node toolchain to exist first, previously an undeclared/accidental ordering assumption. +- **`mise_toolchains`/`atuin_service` now have a real, engine-enforced dependency on whichever resource actually installs mise/atuin**, instead of relying on module registration order or a live `exec.LookPath` check inside `Satisfied`/`Converge`. `Resource` gained `Provides`/`NeedsProvider`: a resource declares a capability tag it satisfies (e.g. `base_packages` or `vendor_install_mise` both `Provides: []string{"mise"}`, whichever is real on this platform) and a consumer declares `NeedsProvider: []string{"mise"}` without knowing which concrete resource that resolves to. The engine resolves this to a real dependency edge at `topoSort` time; an unresolvable or ambiguous tag is a hard error. Consolidated `kinds.ResolveBinary`'s PATH-then-`~/.local/bin` resolution into a new `kinds.Command` helper, replacing a copy of the same fix previously duplicated in `mise.go` and `corepack.go`. +- **Anonymous `git clone` no longer hangs waiting on a Keychain prompt.** A successful anonymous HTTPS clone still invoked the configured `credential.helper` afterwards to store credentials — on macOS with Homebrew's default git config, this blocked on a GUI prompt that never comes in a headless `apply` run. Found via direct process-tree inspection of a real hung `apply`, both in `kinds.GitClone` directly and inside tmux's TPM plugin-install scripts (which shell out to `git clone` internally). devboost only ever clones public repos anonymously, so there's never a credential to store — the helper is now disabled per-invocation. + +### Removed +- The bash implementation (`core/`, `modules/`, `build.sh`, `devboost.sh`, `devboost.sh.in`) and its bash-specific test suite. +- `system.package_manager`, `zsh.plugin_manager`, and `packages.optional` config keys, which were bash-only no-ops not carried forward as real config surface in the Go engine (znap's plugin set is still the actual behavior — see `.devboost.yaml.example`). `tmux.plugins` as a user-supplied plugin *list* was also dropped (TPM's plugin set is fixed in code, not user-configurable), but `tmux.plugins.logging.enable` was added back as a real, live key — see the tool-choice review below. + +### Tool-choice review (2026-08-08) +- First pass of the periodic adversarial tool-choice review — see [docs/tool-choice-review-2026-08.md](docs/tool-choice-review-2026-08.md). Findings implemented directly: swapped `zsh-users/zsh-syntax-highlighting` → `zdharma-continuum/fast-syntax-highlighting`; gated `tmux-logging` behind `tmux.plugins.logging.enable` (default `false`, was previously bundled unconditionally); reverted atuin's `filter_mode` to its own upstream default (`global`) instead of devboost's unjustified `directory` override, adding `filter_mode_shell_up_key_binding: directory` separately for quick up-arrow recall; relabeled a few doc comments (starship's `command_timeout`/`add_newline`, `procs`) to state plainly where they're a deliberate deviation or genuine taste call rather than settled consensus. + +### Known gaps carried forward from the bash version (not yet re-implemented) +- Automatic in-tool warning when a user's config predates the current MAJOR version (see [AGENTS.md](AGENTS.md#5-versioning-strategy)). +- Release builds are cut by hand for now (no GitHub Actions release pipeline yet) — see [v2.0.0](https://github.com/rolfsormo/devboost/releases/tag/v2.0.0). +- No GitHub Actions CI runs on this repo — deliberate, not a gap: local development cost is effectively free, GitHub Actions spend is not. All testing (`go build`/`go vet`/`go test ./...`, `tests/test-install.sh`, and real Docker container runs for Linux) is run locally before pushing. + +### Known external gap (not devboost's to fix) +- mise has its own bug resolving `deno@lts` on Linux (malformed release URL — confirmed reproducible on a real Ubuntu 24.04 container, see [issue #15](https://github.com/rolfsormo/devboost/issues/15)). node/python/go/rust all install correctly via the same mechanism; only deno is affected. `Mise()`'s `Converge` already degrades gracefully (warns, doesn't fail the whole run) when this happens. + +## [1.4.0] - 2026-08-08 + +### Added +- `legacy_shell` module: detects shell tooling in a pre-existing `~/.zshrc`/`~/.zprofile` that duplicates what devboost already manages — a leftover `zinit` setup loading the same plugins as devboost's `znap` (`zsh-autosuggestions`, a syntax-highlighting fork), `asdf` sourced alongside devboost's `mise`, or `nvm`'s shell hook (measured at ~850-900ms per login shell) also alongside `mise` — surfaced via `doctor`, and disabled by `apply`/`plan`. Redundant lines are commented out in place with a `# devboost:disabled:` marker rather than deleted, so they can be reviewed or restored by hand at any time; if a user removes the marker themselves, later runs respect that as an explicit override and leave the line alone. Full pre/post snapshots of every edited file are kept in `~/.devboost/backups/` as an audit trail independent of the marker. +- `devboost clean` command: permanently removes lines previously marked `# devboost:disabled:...`. Idempotent and order-independent — it re-derives what to remove by scanning the live file each run, so it works correctly regardless of when or whether `apply` last ran. Respects `--dry-run`. + +### Fixed +- `zsh` module no longer calls `compinit` itself in the generated `.zshrc.devboost`. It ran *before* znap was sourced, but znap redefines `compinit`/`compdef` as no-ops and runs its own deferred, `precmd`-hook-based compinit into a separate dumpfile the moment it loads — so devboost's own call was a wasted full completion rebuild every shell start, immediately superseded by znap's. Removing it, combined with the `legacy_shell` fixes above, took a real-machine's measured login-shell startup from ~1.44s to ~285-305ms (~80% reduction). + +### Motivation +Investigating real-world zsh startup lag surfaced this exact conflict on a live machine: a pre-existing, non-devboost `zinit` setup, `asdf`, and `nvm` were all running fully redundant plugin/version-manager initialization on every shell start, before devboost's own managed config even loaded — compounded by devboost's own generated config doing a second, wasted completion rebuild on top. + ## [1.3.0] - 2026-08-04 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d18986c..9e4fa7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,11 @@ We welcome contributions of all kinds: # or git checkout -b fix/your-bug-fix ``` +4. **Build and confirm the baseline works**: + ```bash + go build ./... + go test ./... -short + ``` ## Development Workflow @@ -35,51 +40,28 @@ We welcome contributions of all kinds: - Follow the coding standards in [AGENTS.md](AGENTS.md) - Keep changes focused and atomic - Add comments explaining *why*, not *what* -- Use the `db_*` function naming convention +- If you're changing which tool a module uses or its defaults, research + the choice first and document the rationale in the module's doc comment + — see [.agents/skills/devboost-module-author/SKILL.md](.agents/skills/devboost-module-author/SKILL.md) + for the process ### 2. Test Your Changes -**Testing is mandatory** - all contributions must be tested before submission. +**Testing is mandatory** — all contributions must be tested before submission. #### Basic Testing Checklist -- [ ] **Build test**: `./build.sh` succeeds -- [ ] **Syntax check**: `bash -n devboost.sh` passes -- [ ] **Plan test**: `./devboost.sh plan` shows expected changes -- [ ] **Idempotency test**: Run `apply` twice - second run should be no-op -- [ ] **Config test**: Test with minimal config and full config -- [ ] **Dry-run test**: `./devboost.sh plan` works correctly - -#### Platform Testing - -We have automated test scripts for all supported platforms: - -**Linux Testing (Docker/Podman):** -```bash -# Test on a specific distribution -./tests/test-linux.sh ubuntu -./tests/test-linux.sh debian -./tests/test-linux.sh fedora -./tests/test-linux.sh arch - -# Test on all distributions -./tests/test-linux.sh all -``` +- [ ] **Build**: `go build ./...` succeeds +- [ ] **Vet**: `go vet ./...` passes +- [ ] **Unit tests**: `go test ./... -short` passes +- [ ] **Full tests** (including the slow real end-to-end apply): `go test ./...` passes +- [ ] **Plan test**: `go run ./cmd/devboost plan` shows expected changes +- [ ] **Idempotency test**: run `apply` twice — the second run should make no `.zshrc`/config changes +- [ ] **Config test**: test with a minimal config and a full config (see `.devboost.yaml.example`) -**Note**: The script automatically uses Docker or Podman (installing Podman if needed). On macOS, Podman is installed via Homebrew. Arch Linux tests are skipped on ARM64 systems. - -**macOS Testing (Sandboxed):** -```bash -# Run tests in a temporary environment (won't affect your config) -./tests/test-macos.sh -``` - -If you're adding platform-specific code: - -- [ ] Run the appropriate test script(s) -- [ ] Test on macOS (if available) using `./tests/test-macos.sh` -- [ ] Test on Linux using `./tests/test-linux.sh [distro]` -- [ ] Document any platform limitations +If you're adding platform-specific code, note in your PR which platforms +you were able to test on — `go test ./...` skips the platform-dependent +end-to-end test on unsupported OSes automatically. See [tests/README.md](tests/README.md) for detailed testing documentation. @@ -87,11 +69,11 @@ See [tests/README.md](tests/README.md) for detailed testing documentation. If you're adding a new module: -- [ ] Test `plan` mode shows correct output -- [ ] Test `apply` mode works correctly -- [ ] Test idempotency (multiple runs) -- [ ] Test with module disabled in config -- [ ] Test error handling (missing dependencies, etc.) +- [ ] Test that `Foo(cfg)` returns no resources when disabled via config +- [ ] Test the default resource shape against a default/fixture config +- [ ] Test idempotency — a resource's `Diff()` should return `nil` once converged +- [ ] Test any `DependsOn` interactions with other modules that touch the same file +- [ ] Write the rationale doc comment (see [AGENTS.md](AGENTS.md) and the module-author skill) ### 3. Update Documentation @@ -99,6 +81,7 @@ If you're adding a new module: - [ ] Update `.devboost.yaml.example` if adding config options - [ ] Update `CHANGELOG.md` with your changes - [ ] Update `AGENTS.md` if changing development guidelines +- [ ] Update `ARCHITECTURE.md` if changing the engine or module structure ### 4. Commit Your Changes @@ -115,30 +98,30 @@ Follow the [CBEAMS commit message style](AGENTS.md#4-commit-message-style-cbeams **Examples:** ``` -feat(zsh): add support for custom znap path +feat(mise): add support for a custom deno version pin -Allow users to configure znap installation path via -.zsh.znap_path in config file. Defaults to ~/.zsh-snap -if not specified. +Allow toolchains.globals.deno to be set to a specific version +instead of only "lts". Defaults to "lts" if not specified. Closes #42 ``` ``` -fix(starship): correct git_status format syntax +fix(git): correct delta.line-numbers config key -The format string was using invalid variable concatenation. -Changed to use $all_status only, which is the correct -starship syntax. +The key was being read as git.delta.lineNumbers, which never +matched a real config key — changed to git.delta.line_numbers +to match the documented example. Fixes #38 ``` ``` -test(linux): add Ubuntu 22.04 testing +test(modules): add cross-module ordering regression test -Verified package installation and module functionality -on Ubuntu 22.04. All modules working correctly. +Verified security's managed block correctly depends on zsh's +File resource having already run, so the file overwrite can't +silently destroy the block. Related to #15 ``` @@ -157,24 +140,28 @@ Then create a Pull Request on GitHub with: ## Adding a New Module -Adding modules is designed to be super easy! See [AGENTS.md](AGENTS.md#3-module-development) for detailed instructions. +Adding modules is designed to be easy. See +[AGENTS.md](AGENTS.md#3-module-development) and +[ARCHITECTURE.md](ARCHITECTURE.md#adding-a-new-module) for detailed +instructions. **Quick checklist:** -1. Create `modules/module_foo.sh` -2. Implement `db_module_foo_register()`, `plan()`, and `apply()` -3. Add to `build.sh` (file inclusion + registration) -4. Add config options to `.devboost.yaml.example` -5. Test thoroughly -6. Update documentation +1. Create `engine/modules/foo.go` with a `Foo(cfg *config.Config) []engine.Resource` function +2. Research the tool choice and write the rationale as a doc comment (see the module-author skill) +3. Add `foo_test.go` +4. Register it in `engine/modules/registry.go`'s `All` slice +5. Add config keys to `.devboost.yaml.example` +6. `go build ./... && go test ./...` +7. Update documentation ## Code Quality Standards -- **Bash best practices**: See [AGENTS.md](AGENTS.md#2-code-quality-standards-2025) -- **Error handling**: Always check return codes, provide helpful messages -- **Performance**: Minimize external calls, cache when appropriate -- **Readability**: Clear function names, consistent naming, focused functions -- **Security**: Never execute user input, validate paths, backup before modify +- **Go idioms**: standard library over reinventing helpers, clear error wrapping (`fmt.Errorf("...: %w", err)`) +- **Error handling**: always check return values; a `CommandGuarded` with no registered implementation fails loudly, never silently +- **Readability**: since most of this code is read (and often written) by both humans and coding agents, prioritize clarity over cleverness +- **Security**: never execute unsanitized user input, validate paths, back up before overwriting a file the user didn't ask devboost to fully own +- **No corners**: never shell out to bypass writing a real diff — see [ARCHITECTURE.md](ARCHITECTURE.md#resource-kinds-providers) ## Testing Requirements @@ -182,25 +169,22 @@ Adding modules is designed to be super easy! See [AGENTS.md](AGENTS.md#3-module- ### Minimum Testing Requirements -1. **Build and syntax**: `./build.sh && bash -n devboost.sh` -2. **Plan mode**: `./devboost.sh plan` (should not error) -3. **Apply mode**: `./devboost.sh apply` (should work) -4. **Idempotency**: Run `apply` twice, second should be no-op -5. **Platform tests**: Run `./tests/test-macos.sh` (macOS) or `./tests/test-linux.sh [distro]` (Linux) +1. **Build and vet**: `go build ./... && go vet ./...` +2. **Unit tests**: `go test ./... -short` +3. **Full tests**: `go test ./...` (includes a real end-to-end apply against a sandboxed `HOME` — slow, touches real package managers) +4. **Idempotency**: run `apply` twice against a temp `HOME`, second run should be a no-op ### Recommended Testing - Test with different config files -- Test error conditions (missing dependencies, etc.) +- Test error conditions (missing dependencies, unregistered `CommandGuarded` IDs, etc.) - Test on different operating systems if possible -- Test edge cases ### Testing on Different Platforms We especially welcome contributions that test and fix issues on: - Different Linux distributions (Ubuntu, Debian, Fedora, Arch) - Different macOS versions -- Different shell versions If you test on a platform, please note it in your PR! @@ -210,8 +194,7 @@ When reporting bugs, please include: 1. **Environment**: - OS and version - - Shell version - - devboost version + - `devboost --version` 2. **Steps to reproduce**: - Exact commands run @@ -223,7 +206,6 @@ When reporting bugs, please include: 4. **Actual behavior**: - What actually happened - Error messages - - Logs (with `--verbose` flag) 5. **Additional context**: - Any relevant system information @@ -244,7 +226,7 @@ When requesting features: 2. Reviewers will check: - Code quality and style - Test coverage - - Documentation updates + - Documentation updates (including module rationale, if applicable) - Backwards compatibility 3. Be open to feedback and suggestions 4. Address review comments promptly @@ -253,6 +235,7 @@ When requesting features: - Check [AGENTS.md](AGENTS.md) for development guidelines - Check [ARCHITECTURE.md](ARCHITECTURE.md) for design details +- Check [.agents/skills/devboost-module-author/SKILL.md](.agents/skills/devboost-module-author/SKILL.md) for the module-research-and-documentation process - Open an issue for questions or discussions - Be respectful and constructive in all interactions @@ -264,4 +247,3 @@ Contributors will be: - Appreciated by the community! 🎉 Thank you for contributing to devboost! - diff --git a/README.md b/README.md index b0348b8..712f206 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # devboost 🚀 -**One command to transform your workstation into a modern, opinionated development environment.** +**A curated, researched shell/dev-tool setup for macOS and Linux, installed with one command.** -Transform your macOS or Linux machine into a productivity powerhouse with a single command. devboost installs and configures the best-in-class tools for modern development, all while preserving your existing customizations. +devboost installs and configures a specific, opinionated set of modern CLI tools — zsh + starship, ripgrep/fd/bat/eza, mise for toolchains, tmux with session persistence — and wires them together correctly (aliases, PATH, shell init order). Every default is picked from real research into what the developer community actually converges on, documented in the code, not just assumed. It edits your shell config to make that happen, but only inside clearly-marked regions it owns, with a real `undo`. ```bash -curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/devboost.sh | bash -s -- apply +curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/install.sh | sh -s -- apply ``` +Already have zinit, asdf, nvm, or oh-my-zsh set up the way you like? Add +`--no-optimizations` to install just the tools and leave your existing shell +setup alone — see [Quick Start](#-quick-start). + > **✨ What you get:** A beautiful shell (zsh + starship), smart navigation (zoxide, fzf), powerful search (ripgrep, fd), modern replacements (bat, eza, dust, duf, procs), seamless toolchain management (mise), and a fully configured tmux setup — all in under 5 minutes. --- @@ -17,7 +21,7 @@ curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/devboost.sh Setting up a development environment is tedious. You spend hours installing tools, configuring shells, tweaking prompts, and setting up aliases. devboost does all of this **automatically** with **sensible defaults** that work out of the box. **Key principles:** -- ✅ **Non-destructive**: Never touches your existing configs — uses managed include files +- ✅ **Reversible**: Edits are confined to clearly-marked managed regions (an include line in `.zshrc`, a marker block in `.tmux.conf`), everything is backed up first, and nothing of yours is ever deleted - ✅ **Idempotent**: Safe to run multiple times — only applies what's needed - ✅ **Opinionated**: Curated selection of best-in-class tools - ✅ **Zero prompts**: Everything works automatically with smart defaults @@ -31,38 +35,42 @@ Setting up a development environment is tedious. You spend hours installing tool ```bash # Download and run in one command -curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/devboost.sh | bash -s -- apply +curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/install.sh | sh -s -- apply + +# Or, to skip touching any existing shell tooling (zinit, asdf, nvm, oh-my-zsh): +curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/install.sh | sh -s -- apply --no-optimizations ``` **That's it!** Your development environment is being set up. Grab a coffee ☕ — this takes a few minutes. +`install.sh` is a small, pure-POSIX-shell bootstrap dispatcher (the same +pattern rustup uses): it detects your OS/architecture, downloads the +matching prebuilt `devboost` binary from the +[latest release](https://github.com/rolfsormo/devboost/releases/latest), +and execs it with whatever arguments you passed. No application logic +lives in the shell script itself, which is what makes it small enough to +actually read before running. + ### Alternative: Review First (More Secure) ```bash -# Download the script -curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/devboost.sh -o /tmp/devboost.sh - -# Review it (recommended) -less /tmp/devboost.sh - -# Run it -bash /tmp/devboost.sh apply +curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/install.sh -o /tmp/install.sh +less /tmp/install.sh # review it — it's short +sh /tmp/install.sh apply ``` -### Install to PATH +### Building from Source ```bash -# Download to a permanent location -curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/devboost.sh -o ~/bin/devboost -chmod +x ~/bin/devboost - -# Ensure ~/bin is in your PATH -export PATH="$HOME/bin:$PATH" - -# Now run from anywhere -devboost apply +git clone https://github.com/rolfsormo/devboost.git +cd devboost +go build -o devboost ./cmd/devboost +./devboost apply ``` +Copy the resulting `devboost` binary to somewhere on your `PATH` (e.g. +`~/bin/devboost`) to run it from anywhere afterward. + --- ## 📦 What Gets Installed @@ -79,7 +87,7 @@ devboost installs and configures a curated set of modern development tools. Here **Shell Configuration:** - **[znap](https://github.com/marlonrichert/zsh-snap)** - Fast zsh plugin manager - **[zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions)** - Suggests commands as you type -- **[zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting)** - Real-time syntax highlighting +- **[fast-syntax-highlighting](https://github.com/zdharma-continuum/fast-syntax-highlighting)** - Real-time syntax highlighting - **[starship](https://github.com/starship/starship)** - Minimal, fast, customizable prompt - **Smart aliases** - `ls` → `eza`, `cat` → `bat`, `grep` → `rg`, `find` → `fd`, `du` → `dust`, `df` → `duf`, `ps` → `procs` @@ -122,7 +130,7 @@ devboost installs and configures a curated set of modern development tools. Here - **[tmux-resurrect](https://github.com/tmux-plugins/tmux-resurrect)** - Restore tmux sessions after restart - **[tmux-continuum](https://github.com/tmux-plugins/tmux-continuum)** - Automatic session saving - **[tmux-yank](https://github.com/tmux-plugins/tmux-yank)** - Copy to system clipboard -- **[tmux-logging](https://github.com/tmux-plugins/tmux-logging)** - Logging capabilities +- **[tmux-logging](https://github.com/tmux-plugins/tmux-logging)** - Logging capabilities (opt-in — set `tmux.plugins.logging.enable: true`, off by default) - Sensible defaults (mouse support, large history, etc.) ### 🔗 Editor & Terminal Integration @@ -182,15 +190,16 @@ devboost [COMMAND] [OPTIONS] - **`apply`** - Set up your environment (default) - **`plan`** - Preview what would change (dry-run) - **`doctor`** - Check system health and prerequisites +- **`undo`** - Reverse a prior [startup optimization](#-startup-optimizations) (zinit/asdf/nvm/oh-my-zsh) - **`uninstall`** - Remove devboost-managed files -- **`migrate-from-oh-my-zsh`** - Remove oh-my-zsh and recover `.zshrc` customizations (destructive — needs `--yes`) +- **`clean`** - Permanently remove devboost-disabled optimization lines and archived directories ### Options - `--config FILE` - Custom config file (default: `~/.devboost.yaml`) - `--dry-run` - Show what would be done without making changes -- `--yes` - Confirm a destructive command (required by `migrate-from-oh-my-zsh`) -- `--verbose, -v` - Enable verbose output +- `--no-optimizations` - Skip [startup optimizations](#-startup-optimizations) for this run; same as `optimize.enable: false` in config +- `--force` - Let `undo` proceed even though something it would restore has changed since it last converged (`undo` refuses by default — see below) - `--help, -h` - Show help message - `--version` - Show version @@ -203,19 +212,85 @@ devboost plan # Set up your environment devboost apply +# Set up your environment, but leave existing shell tooling untouched +devboost apply --no-optimizations + # Check system health devboost doctor # Use custom config devboost apply --config ~/my-config.yaml -# Remove oh-my-zsh and recover your customizations -devboost migrate-from-oh-my-zsh --dry-run # preview first -devboost migrate-from-oh-my-zsh --yes # then actually run it +# Reverse a startup optimization (undoes zinit/asdf/nvm dedup and/or +# an oh-my-zsh migration, whichever devboost actually converged) +devboost undo --dry-run # preview first +devboost undo # then actually run it + +# Permanently remove lines devboost previously disabled (see below) +devboost clean --dry-run # preview first +devboost clean # then actually run it ``` --- +## 🧹 Startup Optimizations + +A machine that's already had shell tooling installed by hand often ends up +running two things that do the same job — one from that earlier setup, one +from devboost — on every single shell start. devboost detects the overlap +and disables the redundant half, so you only pay the startup cost once. + +| Found in your existing setup | Duplicates | Startup cost if left running | +|---|---|---| +| `zinit` loading `zsh-autosuggestions`/syntax highlighting | devboost's `znap` | wasted plugin load | +| `asdf` sourced in `.zshrc` | devboost's `mise` | wasted version-manager init | +| `nvm`'s shell hook sourced in `.zprofile` | devboost's `mise` | ~850–900ms per login shell — measured via `zprof` on a real machine, the single largest contributor found | +| `oh-my-zsh` (framework: its own plugin manager, prompt, curated plugins) | devboost's `znap` + `starship` + curated plugin set | slower startup, and can cause conflicting keybindings/completions | + +All four are converged automatically by a plain `devboost apply` — no +confirmation prompt, no separate command, the same zero-prompt behavior as +every other resource devboost manages. Reversibility is what makes that a +reasonable default, not a pre-execution gate: + +- **zinit, asdf, and nvm**: `apply` comments out just the redundant lines in + place (prefixed `# devboost:disabled:...`) rather than deleting them. +- **oh-my-zsh**: `apply` replicates oh-my-zsh's own uninstaller — removes + `~/.oh-my-zsh` (archived, not deleted), renames your current `.zshrc` to a + timestamped `~/.zshrc.omz-uninstalled-*` backup, and restores + `~/.zshrc.pre-oh-my-zsh` if that pre-install snapshot exists — then + recovers anything you added to `.zshrc` *after* installing oh-my-zsh + (aliases, `PATH` changes, etc.), which the plain uninstaller alone would + otherwise strand in that backup. oh-my-zsh's own template lines + (`ZSH_THEME`, `plugins=(...)`, `source $ZSH/oh-my-zsh.sh`, etc.) are + stripped out first, so only your genuine additions get appended back. + +Every one of these is backed up first (see [File Layout](#file-layout)), and +every one of these is undoable: + +```bash +devboost undo --dry-run # preview what would be restored +devboost undo # restore whatever apply actually converged +``` + +`undo` reverses exactly what happened — it restores commented-out lines to +their original text, and for oh-my-zsh, moves the archived `~/.oh-my-zsh` +back into place and restores `.zshrc` from its pre-migration backup. Each +restored backup is renamed (suffixed `-reverted`, kept on disk rather than +deleted) so running `undo` again afterward correctly reports nothing left to +restore, instead of redoing the same restore. If something `undo` would +restore has changed since it last converged — e.g. you recreated +`~/.oh-my-zsh` by hand after the migration already ran — `undo` refuses and +tells you what changed, since its backups may no longer describe the current +state accurately; pass `--force` if you want it to proceed anyway. + +To skip this detection entirely, up front, use `--no-optimizations` (see +[Quick Start](#-quick-start)) or set `optimize.enable: false` in your config +— the two are equivalent. `devboost clean` is a separate, one-way step: it +permanently deletes lines `apply` previously commented out, instead of +restoring them. + +--- + ## ⚙️ Configuration Everything works out of the box with sensible defaults. Customize by creating `~/.devboost.yaml`: @@ -246,13 +321,17 @@ See [`.devboost.yaml.example`](.devboost.yaml.example) for all available options ## 🛡️ Safety & Philosophy -devboost is designed to be **completely non-destructive**: +Setup touches a few existing files — an include line in `.zshrc`, a +marker block in `.tmux.conf`, redundant lines from other shell tooling +commented out in place, and (if present) oh-my-zsh archived rather than +deleted — but always this way: -- ✅ **Never modifies your files directly** — uses managed include files -- ✅ **Automatic backups** — first-touch backups in `~/.devboost/backups/` +- ✅ **Backed up first** — first-touch backups in `~/.devboost/backups/` before any existing file is touched +- ✅ **Edits confined to managed regions** — everything outside the include line/marker block is left alone; lines are never deleted, only commented out with a `# devboost:disabled:...` marker +- ✅ **Reversible** — `devboost undo` reverses any [startup optimization](#-startup-optimizations) apply converged - ✅ **Idempotent** — safe to run multiple times - ✅ **Preview mode** — use `plan` to see what would change -- ✅ **Easy removal** — `uninstall` removes all managed files +- ✅ **Easy removal** — `uninstall` removes all managed files and blocks ### File Layout @@ -280,23 +359,26 @@ Want to contribute a screenshot? Show off: ## 🧪 Testing -This project has been tested on: -- ✅ macOS (with Podman for Linux testing) -- ✅ Ubuntu/Debian (via Docker/Podman) -- ✅ Fedora (via Docker/Podman) -- ⚠️ Arch Linux (skipped on ARM64 due to image limitations) - -The test suite automatically uses Docker or Podman (installing Podman if needed). See [tests/README.md](tests/README.md) for details. +devboost is a Go binary with a normal Go test suite (`go test ./...`), +including an end-to-end test that builds the real binary and runs +`plan`/`apply`/`doctor` against a sandboxed `HOME`. See +[tests/README.md](tests/README.md) for details. --- ## 📋 Requirements -- bash 3.2+ (macOS system bash works out of the box) -- git -- curl +To install via `install.sh` (recommended): +- curl or wget (to fetch the prebuilt binary) +- git (devboost itself shells out to it for clones/config) - sudo (for package installation on Linux) -- yq or python3 with PyYAML (optional — falls back to basic parser) + +To build from source instead: +- git +- Go (see `go.mod` for the minimum version) + +No bash version requirement, no YAML-parser dependency — devboost is a +single self-contained binary with Go's YAML support built in. ### Supported Operating Systems @@ -311,17 +393,7 @@ The test suite automatically uses Docker or Podman (installing Podman if needed) ### Package Installation Failures -Package installation output is suppressed for cleaner logs. If a package fails to install, the full error output will be displayed to help you troubleshoot. - -Some packages may not be available in all package managers. You can install missing packages manually or add them to your config's `packages.optional` list. - -### YAML Parsing Issues - -Install `yq` for better YAML parsing: -- macOS: `brew install yq` -- Linux: See [yq installation](https://github.com/mikefarah/yq#install) - -Python 3 with PyYAML works as a fallback. +If a package fails to install, the full error output is shown so you can troubleshoot. Some packages may not be available in all package managers — install missing packages manually, or adjust your config's `packages.base` list. ### Tmux Plugins Not Installing @@ -331,20 +403,12 @@ By default, devboost installs plugins automatically via the TPM CLI after writin If you see `command not found: __zoxide_pwd`, ensure zoxide is installed and run `devboost apply` again to regenerate the config. -### Already Using oh-my-zsh? - -devboost provides its own plugin manager ([znap](https://github.com/marlonrichert/zsh-snap)), prompt ([starship](https://github.com/starship/starship)), and curated plugin set (autosuggestions, syntax highlighting). Running oh-my-zsh alongside devboost is redundant and can slow shell startup or cause conflicting keybindings/completions. - -`devboost doctor` will warn if it detects `~/.oh-my-zsh`. To remove it and recover any customizations: - -```bash -devboost migrate-from-oh-my-zsh --dry-run # preview first -devboost migrate-from-oh-my-zsh --yes # actually remove it -``` - -This is destructive, so `--yes` is required to actually run it (dry-run never needs it). It replicates oh-my-zsh's own uninstaller — removes `~/.oh-my-zsh`, renames your current `.zshrc` to a timestamped `~/.zshrc.omz-uninstalled-*` backup, and restores `~/.zshrc.pre-oh-my-zsh` if that pre-install snapshot exists — then recovers anything you added to `.zshrc` *after* installing oh-my-zsh (aliases, `PATH` changes, etc.), which the plain uninstaller alone would otherwise strand in that backup. oh-my-zsh's own template lines (`ZSH_THEME`, `plugins=(...)`, `source $ZSH/oh-my-zsh.sh`, etc.) are stripped out first, so only your genuine additions get appended back. Your file is backed up first (see [File Layout](#file-layout)) before anything is rewritten. +### Already Using oh-my-zsh, zinit, asdf, or nvm? -Review the result, then run `devboost apply` to add devboost's own setup. +See [Startup Optimizations](#-startup-optimizations) — devboost detects +overlap with each of these and converges away from it automatically as +part of `apply`, reversibly (`devboost undo`). Use `--no-optimizations` +to skip this detection entirely instead. --- @@ -382,14 +446,16 @@ devboost follows **Semantic Versioning**: - **PATCH** (1.1.0 → 1.1.1): Bug fixes, safe to upgrade - **MINOR** (1.1.0 → 1.2.0): New features, safe to upgrade -- **MAJOR** (1.1.0 → 2.0.0): Breaking changes, review changelog +- **MAJOR** (1.1.0 → 2.0.0): Breaking changes, review [CHANGELOG.md](CHANGELOG.md) -The script will warn if your config file is from an older MAJOR version. +Check `devboost --version` and the changelog before upgrading across a +MAJOR version. (Automatic in-tool warnings for an outdated config version +aren't implemented yet — see the changelog manually for now.) --- **Ready to boost your development environment?** 🚀 ```bash -curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/devboost.sh | bash -s -- apply +curl -fsSL https://raw.githubusercontent.com/rolfsormo/devboost/main/install.sh | sh -s -- apply ``` diff --git a/build.sh b/build.sh deleted file mode 100755 index dea8e2e..0000000 --- a/build.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# Build script - concatenates all modules into single devboost.sh - -set -euo pipefail - -OUT="devboost.sh" - -{ - # Entry point - cat devboost.sh.in - - # Core framework (order matters) - echo "" - echo "# === Core Framework ===" - cat core/core_log.sh - echo "" - cat core/core_os.sh - echo "" - cat core/core_yaml.sh - echo "" - cat core/core_files.sh - echo "" - cat core/core_omz.sh - echo "" - cat core/core_modules.sh - echo "" - cat core/core_main.sh - - # Modules (order matters - dependencies first) - echo "" - echo "# === Modules ===" - cat modules/module_pkg.sh - echo "" - cat modules/module_znap.sh - echo "" - cat modules/module_zsh.sh - echo "" - cat modules/module_starship.sh - echo "" - cat modules/module_tmux.sh - echo "" - cat modules/module_mise.sh - echo "" - cat modules/module_corepack.sh - echo "" - cat modules/module_direnv.sh - echo "" - cat modules/module_git.sh - echo "" - cat modules/module_services.sh - echo "" - cat modules/module_security.sh - - # Module registration (must be after all modules are defined) - echo "" - echo "# === Module Registration ===" - cat << 'REGEOF' -db_load_modules() { - # Register all modules - db_module_pkg_register - db_module_znap_register - db_module_zsh_register - db_module_starship_register - db_module_tmux_register - db_module_mise_register - db_module_corepack_register - db_module_direnv_register - db_module_git_register - db_module_services_register - db_module_security_register - - db_log_verbose "Loaded ${#DB_MODULE_NAMES[@]} modules" -} -REGEOF - - # Main execution - echo "" - echo "# === Main Execution ===" - cat << 'MAINEOF' -# Run main if script is executed directly -# Use ${BASH_SOURCE[0]:-} to handle unbound variable (when piped from stdin) -# When piped: BASH_SOURCE[0] is unbound/empty, $0 is usually "-bash" or starts with "-" -# When executed directly: BASH_SOURCE[0] == $0 -# When sourced: BASH_SOURCE[0] != $0 (and we don't want to run) -_bash_source="${BASH_SOURCE[0]:-}" -if [[ "$_bash_source" == "${0}" ]] || [[ -z "$_bash_source" ]]; then - coreMain "$@" -fi -MAINEOF - -} > "$OUT" - -chmod +x "$OUT" - -echo "Built: $OUT" -echo "Size: $(wc -l < "$OUT") lines" - diff --git a/cmd/devboost/integration_test.go b/cmd/devboost/integration_test.go new file mode 100644 index 0000000..be8b9fb --- /dev/null +++ b/cmd/devboost/integration_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestSandboxedApplyPlanDoctorIdempotent builds the real binary, points +// HOME at a fresh temp directory, and runs plan/apply/doctor for real — +// this touches real brew/git, same as a genuine `devboost apply` would, +// since the point is verifying the actual end-to-end binary works, not +// just the in-process resource graph TestAllResourcesResolveWithDefaultConfig +// already covers. Skipped outside macOS/Linux since package installation +// behavior is platform-dependent. +func TestSandboxedApplyPlanDoctorIdempotent(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow end-to-end integration test in -short mode") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("skipping on %s — package installation behavior is platform-dependent", runtime.GOOS) + } + + bin := buildDevboost(t) + home := t.TempDir() + + writeFile(t, filepath.Join(home, ".devboost.yaml"), "version: \"1.0.0\"\n") + + // Overriding only HOME does NOT fully sandbox this test — two + // separate real gaps, both confirmed by direct investigation of a + // real hang in this test on a real dev machine: + // + // 1. XDG-aware tools (confirmed for real — mise) read + // $XDG_CONFIG_HOME directly, inherited unset from os.Environ() + // otherwise. + // 2. mise's own config discovery walks UP the process's working + // directory tree looking for config files — not HOME or + // XDG_CONFIG_HOME at all for this part. Since the test binary's + // subprocess inherits cmd.Dir unset (= the repo checkout's own + // working directory, itself a real subdirectory of the real + // $HOME), mise found and tried to load the developer's actual + // ~/.config/mise/config.toml regardless of every env var + // override above — confirmed directly: `env -i HOME=/tmp/fake + // mise config`, run from inside this repo checkout, still + // resolved the real global config; the same command run from + // /tmp instead found nothing. mise then refused to proceed + // because that real config file isn't "trusted" from this + // process's perspective (a real mise security feature) — which + // is what actually produced the observed hang, not an infinite + // loop in devboost's own code. + // + // Fixed by setting cmd.Dir to the sandboxed home for every + // subprocess call below, so mise's upward directory walk starts (and + // stays) inside the sandbox — matching what a genuinely fresh + // machine's working directory tree looks like, not this repo's own + // checkout location. + env := append(os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME="+filepath.Join(home, ".config"), + "XDG_DATA_HOME="+filepath.Join(home, ".local", "share"), + "XDG_CACHE_HOME="+filepath.Join(home, ".cache"), + "XDG_STATE_HOME="+filepath.Join(home, ".local", "state"), + ) + + run := func(args ...string) (string, error) { + cmd := exec.Command(bin, args...) + cmd.Env = env + cmd.Dir = home + out, err := cmd.CombinedOutput() + return string(out), err + } + + if _, err := run("plan"); err != nil { + t.Fatalf("plan failed: %v", err) + } + if _, err := run("apply", "--dry-run"); err != nil { + t.Fatalf("apply --dry-run failed: %v", err) + } + if _, err := run("doctor"); err != nil { + t.Fatalf("doctor failed: %v", err) + } + + if _, err := run("apply"); err != nil { + t.Fatalf("apply failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(home, ".zshrc.devboost")); err != nil { + t.Fatalf("expected .zshrc.devboost created in temp home: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".zshrc")); err != nil { + t.Fatalf("expected .zshrc created in temp home: %v", err) + } + + realHome, err := os.UserHomeDir() + if err == nil && realHome != home { + if _, err := os.Stat(filepath.Join(realHome, ".zshrc.devboost.__devboost_test_marker_should_not_exist")); err == nil { + t.Fatal("unexpectedly found a test marker in the real home directory") + } + } + + firstZshrc, err := os.ReadFile(filepath.Join(home, ".zshrc")) + if err != nil { + t.Fatal(err) + } + + if _, err := run("apply"); err != nil { + t.Fatalf("second apply failed: %v", err) + } + + secondZshrc, err := os.ReadFile(filepath.Join(home, ".zshrc")) + if err != nil { + t.Fatal(err) + } + if string(firstZshrc) != string(secondZshrc) { + t.Fatalf("expected second apply to be idempotent (no .zshrc changes), got a diff:\nfirst:\n%s\nsecond:\n%s", firstZshrc, secondZshrc) + } + + // undo right after a clean apply must proceed (regression test for + // the bug where a naive whole-system pending-diff pre-check always + // refused, because always-rerun resources like mise/tmux/corepack + // never show zero pending diffs even when nothing has drifted). + if out, err := run("undo", "--dry-run"); err != nil { + t.Fatalf("undo --dry-run failed: %v\n%s", err, out) + } else if !strings.Contains(out, "Would:") && !strings.Contains(out, "Nothing to undo") { + t.Fatalf("expected undo --dry-run to either preview a restore or report nothing to undo, got:\n%s", out) + } +} + +// TestSandboxedUndoRefusesOnDrift builds its own sandboxed home with a +// fake pre-existing oh-my-zsh installation, applies (which genuinely +// migrates away from it), then simulates the user recreating +// ~/.oh-my-zsh afterward — undo must refuse without --force and proceed +// with it. Kept separate from TestSandboxedApplyPlanDoctorIdempotent so +// the oh-my-zsh setup here can't perturb that test's own .zshrc-content +// assertions, which assume no oh-my-zsh is present. +func TestSandboxedUndoRefusesOnDrift(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow end-to-end integration test in -short mode") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("skipping on %s — package installation behavior is platform-dependent", runtime.GOOS) + } + + bin := buildDevboost(t) + home := t.TempDir() + + writeFile(t, filepath.Join(home, ".devboost.yaml"), "version: \"1.0.0\"\n") + omzDir := filepath.Join(home, ".oh-my-zsh") + if err := os.MkdirAll(omzDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(omzDir, "oh-my-zsh.sh"), "# fake oh-my-zsh\n") + writeFile(t, filepath.Join(home, ".zshrc"), + "export ZSH=\"$HOME/.oh-my-zsh\"\nsource $ZSH/oh-my-zsh.sh\nexport MY_VAR=\"hello\"\n") + + env := append(os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME="+filepath.Join(home, ".config"), + "XDG_DATA_HOME="+filepath.Join(home, ".local", "share"), + "XDG_CACHE_HOME="+filepath.Join(home, ".cache"), + "XDG_STATE_HOME="+filepath.Join(home, ".local", "state"), + ) + run := func(args ...string) (string, error) { + cmd := exec.Command(bin, args...) + cmd.Env = env + cmd.Dir = home + out, err := cmd.CombinedOutput() + return string(out), err + } + + if out, err := run("apply"); err != nil { + t.Fatalf("apply failed: %v\n%s", err, out) + } + if _, err := os.Stat(omzDir); !os.IsNotExist(err) { + t.Fatalf("expected apply to have migrated ~/.oh-my-zsh away, stat err: %v", err) + } + + // Simulate the user recreating ~/.oh-my-zsh after the migration + // already ran — real drift, since it no longer matches what undo's + // own backups describe. + if err := os.MkdirAll(omzDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(omzDir, "oh-my-zsh.sh"), "# reappeared after migration\n") + + out, err := run("undo", "--dry-run") + if err != nil { + t.Fatalf("undo --dry-run (drifted) failed: %v\n%s", err, out) + } + if !strings.Contains(out, "Refusing to undo") { + t.Fatalf("expected undo to refuse when omz_migration has drifted, got:\n%s", out) + } + + out, err = run("undo", "--dry-run", "--force") + if err != nil { + t.Fatalf("undo --dry-run --force failed: %v\n%s", err, out) + } + if strings.Contains(out, "Refusing to undo") { + t.Fatalf("expected --force to bypass the drift refusal, got:\n%s", out) + } +} + +func buildDevboost(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "devboost") + cmd := exec.Command("go", "build", "-o", bin, ".") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build devboost: %v\n%s", err, out) + } + return bin +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/devboost/main.go b/cmd/devboost/main.go new file mode 100644 index 0000000..e07e79c --- /dev/null +++ b/cmd/devboost/main.go @@ -0,0 +1,267 @@ +// Command devboost is the CLI: a Terraform-inspired typed-resource +// engine that replaced the original bash implementation (a hand-written +// plan/apply function pair per module, which had already drifted out of +// sync in production). One diff function (engine.ComputeDiff) now +// backs both plan and apply, closing that whole bug class structurally +// rather than by convention. +package main + +import ( + "fmt" + "os" + + "github.com/rolfsormo/devboost/config" + "github.com/rolfsormo/devboost/engine" + "github.com/rolfsormo/devboost/engine/kinds" + "github.com/rolfsormo/devboost/engine/modules" +) + +const usage = `devboost - Bootstrap a modern dev environment + +Usage: devboost [COMMAND] [OPTIONS] + +Commands: + apply Converge machine to config (default) + plan Show actions without changing anything + doctor Check prerequisites and report per-module findings + undo Reverse a prior optimization (zinit/asdf/nvm/oh-my-zsh dedup) + uninstall Remove managed files/blocks (leaves user custom files untouched) + clean Remove devboost-disabled optimization lines and archived dirs + +Options: + --config FILE Config file path (default: ~/.devboost.yaml) + --dry-run Show what would be done without making changes + --no-optimizations Skip the startup optimizations (zinit/asdf/nvm/oh-my-zsh + dedup) for this run; same as optimize.enable: false in config + --force Let 'undo' proceed even though something it would + restore has changed since it last converged + (undo refuses by default) + --help, -h Show this help message + --version Show version +` + +const version = "2.0.0" + +type flags struct { + cmd string + configPath string + dryRun bool + noOptimizations bool + force bool +} + +func main() { + f := parseArgs(os.Args[1:]) + + cfg, err := config.Load(f.configPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error loading config:", err) + os.Exit(1) + } + if f.noOptimizations { + cfg.Set("optimize.enable", "false") + } + + detectedOS := kinds.DetectOS() + + switch f.cmd { + case "plan": + err = engine.Plan(modules.AllResources(cfg, detectedOS)) + case "apply": + if f.dryRun { + err = engine.Plan(modules.AllResources(cfg, detectedOS)) + } else { + err = engine.Apply(modules.AllResources(cfg, detectedOS)) + } + case "doctor": + err = runDoctor(cfg, detectedOS) + case "undo": + err = runUndo(cfg, detectedOS, f.dryRun, f.force) + case "uninstall": + err = modules.Uninstall(cfg) + case "clean": + err = modules.Clean(cfg, f.dryRun) + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", f.cmd, usage) + os.Exit(1) + } + + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +// parseArgs is intentionally small: a subcommand token plus +// --config/--dry-run/--no-optimizations, mirroring the bash tool's +// db_parse_flags for the subset of flags this CLI currently supports. +// --verbose is not yet wired (nothing here has a verbose-vs-normal +// output distinction yet). +func parseArgs(args []string) flags { + f := flags{cmd: "apply", configPath: config.DefaultPath()} + + i := 0 + if len(args) > 0 { + switch args[0] { + case "apply", "plan", "doctor", "undo", "uninstall", "clean": + f.cmd = args[0] + i = 1 + case "--help", "-h": + fmt.Print(usage) + os.Exit(0) + case "--version": + fmt.Println("devboost", version) + os.Exit(0) + } + } + + for ; i < len(args); i++ { + switch args[i] { + case "--config": + if i+1 < len(args) { + f.configPath = args[i+1] + i++ + } + case "--dry-run": + f.dryRun = true + case "--no-optimizations": + f.noOptimizations = true + case "--force": + f.force = true + case "--help", "-h": + fmt.Print(usage) + os.Exit(0) + case "--version": + fmt.Println("devboost", version) + os.Exit(0) + } + } + return f +} + +// runDoctor computes and prints per-module findings, grouped by module +// name — the tool-first grouping from the architecture doc, so this +// stays readable regardless of how many resources/dedup-checks +// accumulate inside any one module over time. +func runDoctor(cfg *config.Config, os kinds.OS) error { + names := make([]string, len(modules.All)) + resourcesByModule := make([][]engine.Resource, len(modules.All)) + diagnosticsByModule := make([]engine.DiagnosticFunc, len(modules.All)) + for i, m := range modules.All { + names[i] = m.Name + resourcesByModule[i] = m.Resources(cfg, os) + if m.Diagnostics != nil { + diagnosticsByModule[i] = m.Diagnostics(cfg, os) + } + } + + reports, err := engine.Doctor(names, resourcesByModule, diagnosticsByModule) + if err != nil { + return err + } + if len(reports) == 0 { + fmt.Println("Everything looks good.") + return nil + } + for _, r := range reports { + fmt.Printf("%s:\n", r.Name) + for _, op := range r.Pending { + fmt.Printf(" ⚠ %s\n", op.Description) + } + for _, d := range r.Diagnostics { + mark := "✓" + if d.Warn { + mark = "⚠" + } + fmt.Printf(" %s %s\n", mark, d.Message) + } + } + return nil +} + +// runUndo reverses whatever the most recent apply converged, for every +// resource whose Kind implements engine.Undoer AND currently has +// something to undo. Checking only the type assertion isn't enough: +// kinds.CommandGuarded implements Undo() unconditionally (it has to, to +// satisfy the interface at all), but returns (nil, nil) for the many +// registered commands with no UndoConverge — e.g. tmux plugin install, +// mise toolchains, corepack all type-assert as engine.Undoer without +// being meaningfully undoable. Calling Undo() up front (read-only, same +// contract Diff() has) and keeping only the resources that actually +// returned a pending op is what correctly narrows this down to the real +// four optimization resources, not everything that happens to share the +// interface. +// +// Refuses to run at all (unless force) when any of those real undoable +// resources themselves have a pending diff — deliberately scoped this +// narrowly, not a whole-system scan: several resources (tmux plugin +// install, mise toolchains, corepack) are correctly "always re-run" — +// their own Satisfied always reports false by design, not because +// anything drifted — so a literal zero-pending-diff-anywhere bar could +// never be met on a real machine. What actually matters for undo's +// correctness is narrower: has the specific thing it's about to restore +// drifted since it last converged (e.g. the user hand-edited the +// disabled line, or re-created ~/.oh-my-zsh) — if so, undo's own +// backups may no longer describe the current state accurately, which is +// exactly the case --force exists for. +func runUndo(cfg *config.Config, os kinds.OS, dryRun, force bool) error { + resources := modules.AllResources(cfg, os) + + type undoTarget struct { + resource engine.Resource + op *engine.PendingOp + } + var targets []undoTarget + for _, r := range resources { + undoer, ok := r.Kind.(engine.Undoer) + if !ok { + continue + } + op, err := undoer.Undo() + if err != nil { + fmt.Printf("%s: error checking undo: %v\n", r.ID, err) + continue + } + if op == nil { + continue + } + targets = append(targets, undoTarget{resource: r, op: op}) + } + + if !force { + var undoable []engine.Resource + for _, t := range targets { + undoable = append(undoable, t.resource) + } + pending, err := engine.ComputeDiff(undoable) + if err != nil { + return err + } + if len(pending) > 0 { + fmt.Println("Refusing to undo: the following have changed since they last converged:") + for _, op := range pending { + fmt.Printf(" ⚠ %s\n", op.Description) + } + fmt.Println("Run 'devboost apply' first, or pass --force to undo anyway.") + return nil + } + } + + for _, t := range targets { + op := t.op + if dryRun { + fmt.Printf("Would: %s\n", op.Description) + continue + } + fmt.Printf("%s...\n", op.Description) + if err := op.Execute(); err != nil { + fmt.Printf("Failed: %s: %v\n", op.Description, err) + continue + } + fmt.Printf("Done: %s\n", op.Description) + } + if len(targets) == 0 { + fmt.Println("Nothing to undo.") + } + return nil +} diff --git a/cmd/devboost/main_test.go b/cmd/devboost/main_test.go new file mode 100644 index 0000000..04539ef --- /dev/null +++ b/cmd/devboost/main_test.go @@ -0,0 +1,68 @@ +package main + +import "testing" + +func TestParseArgsDefaultsToApply(t *testing.T) { + f := parseArgs(nil) + if f.cmd != "apply" { + t.Fatalf("got cmd %q, want %q", f.cmd, "apply") + } +} + +func TestParseArgsRecognizesUndo(t *testing.T) { + f := parseArgs([]string{"undo"}) + if f.cmd != "undo" { + t.Fatalf("got cmd %q, want %q", f.cmd, "undo") + } +} + +func TestParseArgsNoOptimizations(t *testing.T) { + f := parseArgs([]string{"apply", "--no-optimizations"}) + if f.cmd != "apply" { + t.Fatalf("got cmd %q, want %q", f.cmd, "apply") + } + if !f.noOptimizations { + t.Fatal("expected noOptimizations to be true") + } +} + +func TestParseArgsNoOptimizationsDefaultsFalse(t *testing.T) { + f := parseArgs([]string{"apply"}) + if f.noOptimizations { + t.Fatal("expected noOptimizations to default to false") + } +} + +func TestParseArgsUndoWithDryRun(t *testing.T) { + f := parseArgs([]string{"undo", "--dry-run"}) + if f.cmd != "undo" { + t.Fatalf("got cmd %q, want %q", f.cmd, "undo") + } + if !f.dryRun { + t.Fatal("expected dryRun to be true") + } +} + +func TestParseArgsConfigFlag(t *testing.T) { + f := parseArgs([]string{"apply", "--config", "/tmp/custom.yaml"}) + if f.configPath != "/tmp/custom.yaml" { + t.Fatalf("got configPath %q, want %q", f.configPath, "/tmp/custom.yaml") + } +} + +func TestParseArgsUndoWithForce(t *testing.T) { + f := parseArgs([]string{"undo", "--force"}) + if f.cmd != "undo" { + t.Fatalf("got cmd %q, want %q", f.cmd, "undo") + } + if !f.force { + t.Fatal("expected force to be true") + } +} + +func TestParseArgsForceDefaultsFalse(t *testing.T) { + f := parseArgs([]string{"undo"}) + if f.force { + t.Fatal("expected force to default to false") + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3a09085 --- /dev/null +++ b/config/config.go @@ -0,0 +1,168 @@ +// Package config reads devboost's user-facing ~/.devboost.yaml. This stays +// YAML deliberately — it's a pre-existing, user-authored file, unrelated to +// the "module resource declarations are Go struct literals, not YAML" +// decision, which only concerns how devboost's own modules declare +// resources internally. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Config is a loaded ~/.devboost.yaml. Load it once at CLI startup and +// pass it to whatever needs it — no package-level global state, so tests +// can load an arbitrary fixture path without env-var tricks. +type Config struct { + data map[string]any +} + +// Load reads and parses the YAML file at path. A missing file is not an +// error — it's treated as an empty config, so every Get call falls back +// to its default. Only a malformed file (present but unparsable) errors. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{data: map[string]any{}}, nil + } + return nil, err + } + var m map[string]any + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + if m == nil { + m = map[string]any{} + } + return &Config{data: m}, nil +} + +// DefaultPath returns ~/.devboost.yaml for the current user. +func DefaultPath() string { + home, err := os.UserHomeDir() + if err != nil { + return ".devboost.yaml" + } + return filepath.Join(home, ".devboost.yaml") +} + +// Get reads a dotted key path (e.g. "zsh.znap_path"), returning def if +// the key or any intermediate segment is absent. String values (both +// real ones and def itself) are expanded for a leading ~, since defaults +// like "~/.zsh-snap" need that too — expansion happens exactly once, +// regardless of which path returned the value. +// +// A non-string scalar (bool, int, float — e.g. "enable: false" written +// as a real YAML boolean, not a quoted string) is stringified the same +// way the bash tool's yq-based reader renders it in plain output ("true"/ +// "false", plain decimal), so config authors can write natural YAML +// without needing to know devboost's Get treats everything as text +// underneath. A map or list value (wrong shape for a scalar key) falls +// back to def, same as a missing key. +func (c *Config) Get(dottedKey string, def string) string { + return expandHome(c.get(dottedKey, def)) +} + +func (c *Config) get(dottedKey string, def string) string { + cur, ok := c.lookup(dottedKey) + if !ok { + return def + } + switch v := cur.(type) { + case string: + return v + case bool, int, int64, float64: + return fmt.Sprintf("%v", v) + default: + return def + } +} + +// GetList reads a dotted key path expected to hold a YAML list of +// strings (e.g. "packages.base"), returning nil if the key is absent or +// isn't a list. Non-string list items are stringified the same way Get +// stringifies scalars; items that are neither a string nor a plain +// scalar are skipped rather than erroring, so one malformed entry +// doesn't take down reading the whole list. +func (c *Config) GetList(dottedKey string) []string { + cur, ok := c.lookup(dottedKey) + if !ok { + return nil + } + items, ok := cur.([]any) + if !ok { + return nil + } + var out []string + for _, item := range items { + switch v := item.(type) { + case string: + out = append(out, v) + case bool, int, int64, float64: + out = append(out, fmt.Sprintf("%v", v)) + } + } + return out +} + +// lookup walks a dotted key path through the loaded config, returning the +// raw value at that path (whatever type it happens to be) and whether it +// was found at all. +func (c *Config) lookup(dottedKey string) (any, bool) { + cur := any(c.data) + for _, part := range strings.Split(strings.Trim(dottedKey, "."), ".") { + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + v, ok := m[part] + if !ok { + return nil, false + } + cur = v + } + return cur, true +} + +// Set writes value at dottedKey, creating intermediate maps as needed — +// the write-side mirror of lookup's read-side walk, so a value set here +// is indistinguishable from Get's perspective from one the user wrote in +// ~/.devboost.yaml. Used for CLI-flag overrides (e.g. --no-optimizations) +// that need to behave exactly as if the user had written the equivalent +// config key: Get can't tell the difference afterward, which is the +// point — one code path (Get's existing default-handling) stays the only +// place that decides what a key's absence/presence means, rather than a +// second, parallel "was this overridden by a flag" concept. +func (c *Config) Set(dottedKey string, value any) { + parts := strings.Split(strings.Trim(dottedKey, "."), ".") + cur := c.data + for _, part := range parts[:len(parts)-1] { + next, ok := cur[part].(map[string]any) + if !ok { + next = map[string]any{} + cur[part] = next + } + cur = next + } + cur[parts[len(parts)-1]] = value +} + +func expandHome(s string) string { + if s == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + return s + } + if len(s) >= 2 && s[0] == '~' && s[1] == '/' { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, s[2:]) + } + } + return s +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..d448d03 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,237 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFixture(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".devboost.yaml") + if content != "" { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return path +} + +func TestLoadMissingFileYieldsEmptyConfig(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if got := cfg.Get("zsh.znap_path", "default"); got != "default" { + t.Fatalf("got %q, want default", got) + } +} + +func TestLoadMalformedFileErrors(t *testing.T) { + path := writeFixture(t, "zsh:\n - this is not valid: [") + if _, err := Load(path); err == nil { + t.Fatal("expected an error for malformed YAML") + } +} + +func TestGetReadsNestedKey(t *testing.T) { + path := writeFixture(t, "zsh:\n znap_path: /custom/path\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("zsh.znap_path", "default"); got != "/custom/path" { + t.Fatalf("got %q, want /custom/path", got) + } +} + +func TestGetFallsBackToDefaultWhenKeyAbsent(t *testing.T) { + path := writeFixture(t, "zsh:\n other_key: value\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("zsh.znap_path", "default"); got != "default" { + t.Fatalf("got %q, want default", got) + } +} + +func TestGetFallsBackToDefaultWhenIntermediateSegmentAbsent(t *testing.T) { + path := writeFixture(t, "other:\n key: value\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("zsh.znap_path", "default"); got != "default" { + t.Fatalf("got %q, want default", got) + } +} + +func TestGetFallsBackToDefaultWhenValueIsAMap(t *testing.T) { + path := writeFixture(t, "zsh:\n znap_path:\n nested: true\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("zsh.znap_path", "default"); got != "default" { + t.Fatalf("got %q, want default", got) + } +} + +// TestGetStringifiesBoolean is a regression test: a real YAML boolean +// (enable: false, not "enable: \"false\"") was silently ignored by an +// earlier version of Get — it only recognized string-typed values, so a +// module reading .git.delta.enable would see the default ("true") even +// though the user explicitly wrote false. yq (the bash tool's reader) +// renders YAML booleans as plain "true"/"false" text, so Get must match +// that, not require users to quote their booleans. +func TestGetStringifiesBoolean(t *testing.T) { + path := writeFixture(t, "git:\n delta:\n enable: false\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("git.delta.enable", "true"); got != "false" { + t.Fatalf("got %q, want %q", got, "false") + } +} + +func TestGetStringifiesInteger(t *testing.T) { + path := writeFixture(t, "tmux:\n settings:\n base_index: 1\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("tmux.settings.base_index", "0"); got != "1" { + t.Fatalf("got %q, want %q", got, "1") + } +} + +// TestGetExpandsHomeInDefault is a regression test for a real bug found +// during the znap spike: expansion only ran on values actually read from +// the file, not on the default — so a default like "~/.zsh-snap" with no +// config file present was passed through to git clone literally, +// including the tilde, which git happily "cloned into" as a real +// directory named "~". +func TestGetExpandsHomeInDefault(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory available in this environment") + } + got := cfg.Get("zsh.znap_path", "~/.zsh-snap") + want := filepath.Join(home, ".zsh-snap") + if got != want { + t.Fatalf("got %q, want %q (default was not tilde-expanded)", got, want) + } +} + +func TestGetExpandsHomeInConfiguredValue(t *testing.T) { + path := writeFixture(t, "zsh:\n znap_path: \"~/custom-znap\"\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory available in this environment") + } + got := cfg.Get("zsh.znap_path", "default") + want := filepath.Join(home, "custom-znap") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestGetLeavesNonTildeValuesUntouched(t *testing.T) { + path := writeFixture(t, "zsh:\n znap_path: /absolute/path\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("zsh.znap_path", "default"); got != "/absolute/path" { + t.Fatalf("got %q, want /absolute/path", got) + } +} + +func TestGetListReadsStringItems(t *testing.T) { + path := writeFixture(t, "packages:\n base:\n - zsh\n - tmux\n - fzf\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + got := cfg.GetList("packages.base") + want := []string{"zsh", "tmux", "fzf"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestGetListNilWhenAbsent(t *testing.T) { + path := writeFixture(t, "packages:\n other: value\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.GetList("packages.base"); got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestGetListNilWhenNotAList(t *testing.T) { + path := writeFixture(t, "packages:\n base: not-a-list\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.GetList("packages.base"); got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestSetThenGetRoundTrips(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatal(err) + } + cfg.Set("optimize.enable", "false") + if got := cfg.Get("optimize.enable", "true"); got != "false" { + t.Fatalf("got %q, want %q", got, "false") + } +} + +func TestSetCreatesIntermediateMaps(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatal(err) + } + // No "optimize" key exists at all yet — Set must create it rather + // than panic or silently no-op. + cfg.Set("optimize.enable", "false") + if got := cfg.Get("optimize.enable", "true"); got != "false" { + t.Fatalf("got %q, want %q", got, "false") + } +} + +func TestSetOverridesExistingKeyFromFile(t *testing.T) { + path := writeFixture(t, "optimize:\n enable: true\n") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Get("optimize.enable", "MISSING"); got != "true" { + t.Fatalf("got %q, want %q before Set", got, "true") + } + cfg.Set("optimize.enable", "false") + if got := cfg.Get("optimize.enable", "MISSING"); got != "false" { + t.Fatalf("got %q, want %q after Set", got, "false") + } +} diff --git a/core/core_files.sh b/core/core_files.sh deleted file mode 100644 index f5e10d7..0000000 --- a/core/core_files.sh +++ /dev/null @@ -1,142 +0,0 @@ -# File manipulation helpers - -db_ensure_dir() { - local dir="$1" - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would create directory: $dir" - return 0 - fi - mkdir -p "$dir" -} - -db_backup_file() { - local file="$1" - if [[ ! -f "$file" ]]; then - return 0 - fi - - local backup_dir="${DB_BACKUP_DIR:-$HOME/.devboost/backups}" - local timestamp=$(date +%Y%m%d_%H%M%S) - local backup_path="${backup_dir}/${timestamp}" - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would backup: $file -> ${backup_path}/$(basename "$file")" - return 0 - fi - - db_ensure_dir "$backup_path" - cp "$file" "${backup_path}/$(basename "$file")" - db_log_verbose "Backed up: $file" -} - -db_write_file() { - local file="$1" - local content="$2" - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would write: $file" - echo "$content" - else - db_backup_file "$file" - echo "$content" > "$file" - db_log_success "Wrote: $file" - fi -} - -db_upsert_block() { - local file="$1" - local start_marker="$2" - local end_marker="$3" - local new_content="$4" - - if [[ ! -f "$file" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would create: $file" - else - echo "$new_content" > "$file" - db_log_success "Created: $file" - fi - return 0 - fi - - if grep -q "$start_marker" "$file" 2>/dev/null; then - # Replace existing block - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would replace block in: $file" - else - db_backup_file "$file" - local temp_file=$(mktemp) - local block_file=$(mktemp) - echo "$new_content" > "$block_file" - awk -v start="$start_marker" -v end="$end_marker" -v block_file="$block_file" ' - $0 ~ start { - in_block=1 - while ((getline line < block_file) > 0) { - print line - } - close(block_file) - next - } - $0 ~ end { - in_block=0 - next - } - !in_block { print } - ' "$file" > "$temp_file" - mv "$temp_file" "$file" - rm -f "$block_file" - db_log_success "Updated block in: $file" - fi - else - # Append block - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would append block to: $file" - else - db_backup_file "$file" - echo "" >> "$file" - echo "$new_content" >> "$file" - db_log_success "Injected block into: $file" - fi - fi -} - -db_confirm() { - local prompt="$1" - local default="${2:-y}" - local hint - if [[ "$default" == "y" ]]; then - hint="[Y/n]" - else - hint="[y/N]" - fi - local reply - read -r -p "$(echo -e "${YELLOW}?${NC} ${prompt} ${hint} ")" reply - reply="${reply:-$default}" - reply=$(echo "$reply" | tr '[:upper:]' '[:lower:]') - [[ "$reply" == "y" ]] -} - -db_remove_block() { - local file="$1" - local start_marker="$2" - local end_marker="$3" - - if [[ ! -f "$file" ]] || ! grep -q "$start_marker" "$file" 2>/dev/null; then - return 0 - fi - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove block from: $file" - else - db_backup_file "$file" - local temp_file=$(mktemp) - awk -v start="$start_marker" -v end="$end_marker" ' - $0 ~ start { in_block=1; next } - $0 ~ end { in_block=0; next } - !in_block { print } - ' "$file" > "$temp_file" - mv "$temp_file" "$file" - db_log_success "Removed block from: $file" - fi -} - diff --git a/core/core_log.sh b/core/core_log.sh deleted file mode 100644 index 97ef3aa..0000000 --- a/core/core_log.sh +++ /dev/null @@ -1,36 +0,0 @@ -# Core logging functions - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -db_log_info() { - echo -e "${BLUE}ℹ${NC} $*" -} - -db_log_success() { - echo -e "${GREEN}✓${NC} $*" -} - -db_log_warn() { - echo -e "${YELLOW}⚠${NC} $*" -} - -db_log_error() { - echo -e "${RED}✗${NC} $*" >&2 -} - -db_log_verbose() { - if [[ "${DB_VERBOSE:-false}" == "true" ]]; then - echo -e "${BLUE}[verbose]${NC} $*" - fi -} - -db_die() { - db_log_error "$@" - exit 1 -} - diff --git a/core/core_main.sh b/core/core_main.sh deleted file mode 100644 index 0f5c32d..0000000 --- a/core/core_main.sh +++ /dev/null @@ -1,193 +0,0 @@ -# Main entry point and CLI - -DB_VERSION="1.3.0" -DB_SUBCOMMAND="apply" -DB_DRY_RUN=false -DB_VERBOSE=false -DB_OMZ_YES=false -DB_BACKUP_DIR="${HOME}/.devboost/backups" -DB_STATE_FILE="${HOME}/.devboost.state.json" - -db_parse_flags() { - while [[ $# -gt 0 ]]; do - case $1 in - apply|plan|doctor|uninstall|migrate-from-oh-my-zsh) - DB_SUBCOMMAND="$1" - shift - ;; - --config) - DB_CONFIG_PATH="$2" - shift 2 - ;; - --dry-run) - DB_DRY_RUN=true - shift - ;; - --yes) - DB_OMZ_YES=true - shift - ;; - --verbose|-v) - DB_VERBOSE=true - shift - ;; - --help|-h) - db_show_help - exit 0 - ;; - --version) - echo "devboost $DB_VERSION" - exit 0 - ;; - *) - db_log_error "Unknown option: $1" - db_show_help - exit 1 - ;; - esac - done -} - -db_show_help() { - cat << EOF -devboost - Bootstrap a modern dev environment - -Usage: devboost [COMMAND] [OPTIONS] - -Commands: - apply Converge machine to config (default) - plan Show actions without changing anything - doctor Check prerequisites, PATHs, shells, conflicting files - uninstall Remove managed files/blocks (leaves user custom files untouched) - migrate-from-oh-my-zsh Remove oh-my-zsh and recover .zshrc customizations (needs --yes) - -Options: - --config FILE Config file path (default: ~/.devboost.yaml) - --dry-run Show what would be done without making changes - --yes Confirm a destructive command (required by migrate-from-oh-my-zsh) - --verbose, -v Enable verbose output - --help, -h Show this help message - --version Show version - -EOF -} - -coreMain() { - # Parse command first (before flags) - local cmd="apply" - if [[ $# -gt 0 ]] && [[ "$1" =~ ^(apply|plan|doctor|uninstall|migrate-from-oh-my-zsh)$ ]]; then - cmd="$1" - shift - fi - - db_parse_flags "$@" - - # Override subcommand if it was set via flags (shouldn't happen, but just in case) - DB_SUBCOMMAND="$cmd" - - db_log_info "devboost $DB_VERSION - $DB_SUBCOMMAND" - - # Check config version compatibility - local config_version=$(db_yaml_get '.version' '') - if [[ -n "$config_version" ]]; then - local config_major=$(echo "$config_version" | cut -d. -f1) - local script_major=$(echo "$DB_VERSION" | cut -d. -f1) - - if [[ "$config_major" -lt "$script_major" ]]; then - db_log_warn "Config file version ($config_version) is older than script version ($DB_VERSION)" - db_log_warn "Please review CHANGELOG.md for breaking changes" - fi - fi - - # Ensure backup directory exists - db_ensure_dir "$DB_BACKUP_DIR" - - # Detect OS - db_detect_os - - # Load modules (already sourced, just register) - db_load_modules - - case "$DB_SUBCOMMAND" in - apply) - db_run_apply - db_log_success "Configuration applied!" - ;; - plan) - DB_DRY_RUN=true - db_run_plan - db_log_info "Plan complete" - ;; - doctor) - db_run_doctor - # Also run general diagnostics - db_log_info "OS: $DB_OS" - db_log_info "Current shell: $SHELL" - db_command_exists zsh && db_log_success "zsh: found" || db_log_error "zsh: not found" - db_command_exists git && db_log_success "git: found" || db_log_error "git: not found" - db_command_exists curl && db_log_success "curl: found" || db_log_error "curl: not found" - ;; - uninstall) - db_run_uninstall - ;; - migrate-from-oh-my-zsh) - db_run_migrate_from_oh_my_zsh - ;; - *) - db_die "Unknown command: $DB_SUBCOMMAND" - ;; - esac -} - -db_run_uninstall() { - db_log_warn "Uninstalling devboost managed files..." - - # Remove .zshrc.devboost - local include_file=$(db_yaml_get '.zsh.include_file' "$HOME/.zshrc.devboost") - if [[ -f "$include_file" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $include_file" - else - rm -f "$include_file" - db_log_success "Removed: $include_file" - fi - fi - - # Remove devboost block from .zshrc - local zshrc="${HOME}/.zshrc" - if [[ -f "$zshrc" ]] && grep -q "# >>> devboost include start" "$zshrc" 2>/dev/null; then - db_remove_block "$zshrc" "# >>> devboost include start" "# <<< devboost include end" - fi - - # Remove devboost block from .tmux.conf - local tmux_conf=$(db_yaml_get '.tmux.conf_file' "$HOME/.tmux.conf") - if [[ -f "$tmux_conf" ]] && grep -q "# >>> devboost tmux start" "$tmux_conf" 2>/dev/null; then - db_remove_block "$tmux_conf" "# >>> devboost tmux start" "# <<< devboost tmux end" - fi - - # Remove direnvrc - local direnvrc=$(db_yaml_get '.direnv.rc_path' "$HOME/.direnvrc") - if [[ -f "$direnvrc" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $direnvrc" - else - db_backup_file "$direnvrc" - rm -f "$direnvrc" - db_log_success "Removed: $direnvrc" - fi - fi - - # Remove state file - if [[ -f "$DB_STATE_FILE" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $DB_STATE_FILE" - else - rm -f "$DB_STATE_FILE" - db_log_success "Removed: $DB_STATE_FILE" - fi - fi - - db_log_info "Uninstall complete. Backups are preserved in: $DB_BACKUP_DIR" - db_log_info "Note: Packages, znap, TPM, and mise toolchains are not removed." -} - diff --git a/core/core_modules.sh b/core/core_modules.sh deleted file mode 100644 index 81036f3..0000000 --- a/core/core_modules.sh +++ /dev/null @@ -1,62 +0,0 @@ -# Module registry system -# Uses bash 3.x compatible approach (no associative arrays) - -DB_MODULE_NAMES=() - -# Helper functions for bash 3.x compatibility (simulating associative arrays) -_db_module_set() { - local var="$1" key="$2" value="$3" - # Sanitize key to be a valid variable name - key=$(echo "$key" | tr -cd '[:alnum:]_') - eval "${var}_${key}=\"\$value\"" -} - -_db_module_get() { - local var="$1" key="$2" - # Sanitize key to be a valid variable name - key=$(echo "$key" | tr -cd '[:alnum:]_') - eval "echo \"\${${var}_${key}:-}\"" -} - -db_register_module() { - local name="$1" plan="$2" apply="$3" doctor="${4:-}" - DB_MODULE_NAMES+=("$name") - _db_module_set "DB_MODULE_PLAN_FUNC" "$name" "$plan" - _db_module_set "DB_MODULE_APPLY_FUNC" "$name" "$apply" - if [[ -n "$doctor" ]]; then - _db_module_set "DB_MODULE_DOCTOR_FUNC" "$name" "$doctor" - fi - db_log_verbose "Registered module: $name" -} - -# db_load_modules() is defined after all modules are loaded (in build output) - -db_run_plan() { - db_log_info "Planning changes..." - for m in "${DB_MODULE_NAMES[@]}"; do - db_log_verbose "Planning module: $m" - local func=$(_db_module_get "DB_MODULE_PLAN_FUNC" "$m") - [[ -n "$func" ]] && "$func" || true - done -} - -db_run_apply() { - db_log_info "Applying configuration..." - for m in "${DB_MODULE_NAMES[@]}"; do - db_log_verbose "Applying module: $m" - local func=$(_db_module_get "DB_MODULE_APPLY_FUNC" "$m") - [[ -n "$func" ]] && "$func" || true - done -} - -db_run_doctor() { - db_log_info "Running diagnostics..." - for m in "${DB_MODULE_NAMES[@]}"; do - local func=$(_db_module_get "DB_MODULE_DOCTOR_FUNC" "$m") - if [[ -n "$func" ]]; then - db_log_verbose "Checking module: $m" - "$func" || true - fi - done -} - diff --git a/core/core_omz.sh b/core/core_omz.sh deleted file mode 100644 index c005cad..0000000 --- a/core/core_omz.sh +++ /dev/null @@ -1,189 +0,0 @@ -# oh-my-zsh removal + migration helper -# -# This command replicates oh-my-zsh's own tools/uninstall.sh (removes -# ~/.oh-my-zsh, renames the current ~/.zshrc to -# ~/.zshrc.omz-uninstalled-, and restores ~/.zshrc.pre-oh-my-zsh -# if that pre-install snapshot exists) and then recovers any customization -# the user made *after* installing oh-my-zsh (aliases, PATH tweaks, exports) -# that would otherwise be stranded in the timestamped backup. -# -# Because the restore step always makes ~/.zshrc identical to the pre-install -# base (when a base exists), recovery reduces to: lines present in the -# timestamped backup but absent from the base are the user's additions — -# append them, after stripping oh-my-zsh's own template lines (ZSH_THEME, -# plugins=, source $ZSH/oh-my-zsh.sh, etc., which differ from the base too -# but aren't user content). There's no real 3-way conflict to resolve here -# since "current" and "base" always match going in. -# -# This is its own explicit subcommand — separate from apply/plan/doctor/ -# uninstall — precisely because it removes a directory and rewrites .zshrc; -# it never runs as a side effect of anything else, and requires --yes. - -# Lines matching these patterns are oh-my-zsh's own template scaffolding, -# not user content, even though their values are user-customized (e.g. -# ZSH_THEME). Matched against templates/zshrc.zsh-template upstream. -_db_omz_is_template_line() { - local line="$1" - case "$line" in - '#'*) return 0 ;; - '') return 0 ;; - 'export ZSH='*) return 0 ;; - 'ZSH_THEME='*) return 0 ;; - 'ZSH_THEME_RANDOM_CANDIDATES='*) return 0 ;; - 'CASE_SENSITIVE='*) return 0 ;; - 'HYPHEN_INSENSITIVE='*) return 0 ;; - 'DISABLE_MAGIC_FUNCTIONS='*) return 0 ;; - 'DISABLE_LS_COLORS='*) return 0 ;; - 'DISABLE_AUTO_TITLE='*) return 0 ;; - 'ENABLE_CORRECTION='*) return 0 ;; - 'COMPLETION_WAITING_DOTS='*) return 0 ;; - 'DISABLE_UNTRACKED_FILES_DIRTY='*) return 0 ;; - 'HIST_STAMPS='*) return 0 ;; - 'ZSH_CUSTOM='*) return 0 ;; - 'zstyle '*':omz:'*) return 0 ;; - 'plugins=('*) return 0 ;; - 'source $ZSH/oh-my-zsh.sh'*) return 0 ;; - *) return 1 ;; - esac -} - -# Strip oh-my-zsh template lines from a file, writing the remainder to stdout. -_db_omz_strip_template() { - local file="$1" - local line - while IFS= read -r line || [[ -n "$line" ]]; do - _db_omz_is_template_line "$line" || echo "$line" - done < "$file" -} - -# Finds the most recently modified ~/.zshrc.omz-uninstalled-* backup, if any. -_db_omz_find_uninstalled_backup() { - local candidate - candidate=$(ls -t "$HOME"/.zshrc.omz-uninstalled-* 2>/dev/null | head -1) || true - echo "$candidate" -} - -# Replicates oh-my-zsh's tools/uninstall.sh: remove ~/.oh-my-zsh, rename the -# current ~/.zshrc to a timestamped backup, and restore ~/.zshrc.pre-oh-my-zsh -# if that pre-install snapshot exists. -_db_omz_uninstall() { - local omz_dir="$HOME/.oh-my-zsh" - local zshrc="$HOME/.zshrc" - local base="$HOME/.zshrc.pre-oh-my-zsh" - - if [[ ! -d "$omz_dir" ]]; then - db_log_verbose "No ~/.oh-my-zsh found — already removed or never installed." - return 0 - fi - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $omz_dir" - [[ -f "$zshrc" ]] && db_log_info "Would rename $zshrc to a timestamped .omz-uninstalled-* backup" - [[ -f "$base" ]] && db_log_info "Would restore $base to $zshrc" - return 0 - fi - - rm -rf "$omz_dir" - db_log_success "Removed: $omz_dir" - - if [[ -f "$zshrc" ]]; then - local saved="$HOME/.zshrc.omz-uninstalled-$(date +%Y-%m-%d_%H-%M-%S)" - mv "$zshrc" "$saved" - db_log_info "Renamed $zshrc to: $saved" - fi - - if [[ -f "$base" ]]; then - mv "$base" "$zshrc" - db_log_success "Restored pre-oh-my-zsh config to: $zshrc" - else - db_log_info "No ~/.zshrc.pre-oh-my-zsh found — nothing to restore." - fi -} - -# Writes to stdout the lines in $other that are not present anywhere in $base, -# after stripping oh-my-zsh template lines from $other. Order-preserving, -# duplicate-preserving (does not dedupe repeated lines within $other itself). -_db_omz_lines_only_in() { - local other="$1" base="$2" - local stripped_other - stripped_other=$(mktemp) - _db_omz_strip_template "$other" > "$stripped_other" - - if [[ -n "$base" ]] && [[ -f "$base" ]]; then - grep -vFxf "$base" "$stripped_other" 2>/dev/null || true - else - cat "$stripped_other" - fi - rm -f "$stripped_other" -} - -db_run_migrate_from_oh_my_zsh() { - local zshrc="$HOME/.zshrc" - local base="$HOME/.zshrc.pre-oh-my-zsh" - - if [[ "${DB_DRY_RUN:-false}" != "true" ]] && [[ "${DB_OMZ_YES:-false}" != "true" ]]; then - db_log_error "This removes ~/.oh-my-zsh and rewrites ~/.zshrc — pass --yes to confirm." - db_log_info "Preview first with: devboost migrate-from-oh-my-zsh --dry-run" - return 1 - fi - - # _db_omz_uninstall moves $base to $zshrc (replicating oh-my-zsh's own - # uninstaller), consuming it — so snapshot its content first, since we - # still need it below to tell the base's own lines apart from the user's - # genuine post-install additions in the timestamped backup. - local base_snapshot="" - if [[ -f "$base" ]] && [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - base_snapshot=$(mktemp) - cp "$base" "$base_snapshot" - fi - - _db_omz_uninstall - - local uninstalled - uninstalled=$(_db_omz_find_uninstalled_backup) - - if [[ -z "$uninstalled" ]]; then - [[ -n "$base_snapshot" ]] && rm -f "$base_snapshot" - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "No existing ~/.zshrc.omz-uninstalled-* backup yet — nothing further to recover in a dry-run." - return 0 - fi - db_log_error "No ~/.zshrc.omz-uninstalled-* backup found — nothing to recover." - return 1 - fi - db_log_info "Found uninstall backup: $uninstalled" - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - if [[ -f "$base" ]]; then - db_log_info "Would recover your additions from '$(basename "$uninstalled")' not already in '$(basename "$base")'" - else - db_log_info "Would recover your additions from '$(basename "$uninstalled")' (no pre-install base to compare against)" - fi - db_log_info "Would append them to: $zshrc" - return 0 - fi - - local additions - additions=$(_db_omz_lines_only_in "$uninstalled" "$base_snapshot") - rm -f "$base_snapshot" - - if [[ -z "$additions" ]]; then - db_log_success "No post-install customizations found beyond oh-my-zsh's own template — nothing to recover." - db_log_info "Review $zshrc, then run 'devboost apply' to add devboost's include block." - return 0 - fi - - db_backup_file "$zshrc" - { - if [[ -f "$zshrc" ]]; then - cat "$zshrc" - echo "" - fi - echo "$additions" - } > "${zshrc}.devboost-omz-tmp" - mv "${zshrc}.devboost-omz-tmp" "$zshrc" - - db_log_success "Recovered your customizations into: $zshrc" - db_log_info "Review the result, then run 'devboost apply' to add devboost's include block." - return 0 -} diff --git a/core/core_os.sh b/core/core_os.sh deleted file mode 100644 index 1a3496b..0000000 --- a/core/core_os.sh +++ /dev/null @@ -1,122 +0,0 @@ -# OS detection and package manager abstraction - -db_detect_os() { - if [[ "$OSTYPE" == "darwin"* ]]; then - DB_OS="darwin" - elif [[ -f /etc/debian_version ]]; then - DB_OS="linux-ubuntu" - elif [[ -f /etc/fedora-release ]]; then - DB_OS="linux-fedora" - elif [[ -f /etc/arch-release ]]; then - DB_OS="linux-arch" - else - DB_OS="other" - fi - db_log_verbose "Detected OS: $DB_OS" -} - -db_install_packages() { - local pkgs=("$@") - if [[ ${#pkgs[@]} -eq 0 ]]; then - return 0 - fi - - case "$DB_OS" in - darwin) - if ! command -v brew >/dev/null 2>&1; then - db_log_info "Installing Homebrew..." - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - else - db_log_info "Would install Homebrew" - fi - fi - for pkg in "${pkgs[@]}"; do - if ! brew list "$pkg" &>/dev/null; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(brew install "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - linux-ubuntu) - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - sudo apt-get update -qq - fi - for pkg in "${pkgs[@]}"; do - if ! dpkg -l | grep -q "^ii[[:space:]]*${pkg}[[:space:]]"; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(sudo apt-get install -y "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - linux-fedora) - for pkg in "${pkgs[@]}"; do - if ! rpm -q "$pkg" &>/dev/null; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(sudo dnf install -y "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - linux-arch) - for pkg in "${pkgs[@]}"; do - if ! pacman -Qi "$pkg" &>/dev/null; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(sudo pacman -S --noconfirm "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - *) - db_log_error "Unsupported OS: $DB_OS" - return 1 - ;; - esac -} - -db_command_exists() { - command -v "$1" >/dev/null 2>&1 -} - diff --git a/core/core_yaml.sh b/core/core_yaml.sh deleted file mode 100644 index 70729a8..0000000 --- a/core/core_yaml.sh +++ /dev/null @@ -1,56 +0,0 @@ -# YAML config handling via yq - -DB_CONFIG_PATH="${DB_CONFIG_PATH:-$HOME/.devboost.yaml}" - -db_yaml_get() { - local path="$1" default="${2-}" - if [[ -f "$DB_CONFIG_PATH" ]]; then - if db_command_exists yq; then - local result - result=$(yq "$path" "$DB_CONFIG_PATH" 2>/dev/null || echo "$default") - # Handle null values from yq - if [[ "$result" == "null" ]] || [[ -z "$result" ]]; then - echo "$default" - else - echo "$result" - fi - elif db_command_exists python3 && python3 -c "import yaml" 2>/dev/null; then - python3 -c " -import yaml -import sys -try: - with open('$DB_CONFIG_PATH', 'r') as f: - data = yaml.safe_load(f) or {} - def get_nested(d, keys): - for k in keys.split('.'): - if isinstance(d, dict) and k in d: - d = d[k] - else: - return None - return d - result = get_nested(data, '$path') - print(result if result is not None else '$default') -except: - print('$default') -" - else - echo "$default" - fi - else - echo "$default" - fi -} - -db_yaml_get_list() { - local path="$1" - if [[ -f "$DB_CONFIG_PATH" ]]; then - if db_command_exists yq; then - yq -e "$path[]" "$DB_CONFIG_PATH" 2>/dev/null | tr '\n' ' ' || echo "" - else - echo "" - fi - else - echo "" - fi -} - diff --git a/devboost.sh b/devboost.sh deleted file mode 100755 index 6ae650f..0000000 --- a/devboost.sh +++ /dev/null @@ -1,1859 +0,0 @@ -#!/usr/bin/env bash -# devboost - Bootstrap a modern dev environment -# Idempotent, config-driven, non-destructive -# This file is the entry point - core and modules are concatenated during build - -set -euo pipefail - -# Version is set in core_main.sh - - -# === Core Framework === -# Core logging functions - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -db_log_info() { - echo -e "${BLUE}ℹ${NC} $*" -} - -db_log_success() { - echo -e "${GREEN}✓${NC} $*" -} - -db_log_warn() { - echo -e "${YELLOW}⚠${NC} $*" -} - -db_log_error() { - echo -e "${RED}✗${NC} $*" >&2 -} - -db_log_verbose() { - if [[ "${DB_VERBOSE:-false}" == "true" ]]; then - echo -e "${BLUE}[verbose]${NC} $*" - fi -} - -db_die() { - db_log_error "$@" - exit 1 -} - - -# OS detection and package manager abstraction - -db_detect_os() { - if [[ "$OSTYPE" == "darwin"* ]]; then - DB_OS="darwin" - elif [[ -f /etc/debian_version ]]; then - DB_OS="linux-ubuntu" - elif [[ -f /etc/fedora-release ]]; then - DB_OS="linux-fedora" - elif [[ -f /etc/arch-release ]]; then - DB_OS="linux-arch" - else - DB_OS="other" - fi - db_log_verbose "Detected OS: $DB_OS" -} - -db_install_packages() { - local pkgs=("$@") - if [[ ${#pkgs[@]} -eq 0 ]]; then - return 0 - fi - - case "$DB_OS" in - darwin) - if ! command -v brew >/dev/null 2>&1; then - db_log_info "Installing Homebrew..." - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - else - db_log_info "Would install Homebrew" - fi - fi - for pkg in "${pkgs[@]}"; do - if ! brew list "$pkg" &>/dev/null; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(brew install "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - linux-ubuntu) - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - sudo apt-get update -qq - fi - for pkg in "${pkgs[@]}"; do - if ! dpkg -l | grep -q "^ii[[:space:]]*${pkg}[[:space:]]"; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(sudo apt-get install -y "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - linux-fedora) - for pkg in "${pkgs[@]}"; do - if ! rpm -q "$pkg" &>/dev/null; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(sudo dnf install -y "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - linux-arch) - for pkg in "${pkgs[@]}"; do - if ! pacman -Qi "$pkg" &>/dev/null; then - db_log_info "Installing: $pkg" - if [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - local output - output=$(sudo pacman -S --noconfirm "$pkg" 2>&1) || { - db_log_error "Failed to install $pkg" - echo "$output" >&2 - return 1 - } - else - db_log_info "Would install: $pkg" - fi - else - db_log_verbose "Already installed: $pkg" - fi - done - ;; - *) - db_log_error "Unsupported OS: $DB_OS" - return 1 - ;; - esac -} - -db_command_exists() { - command -v "$1" >/dev/null 2>&1 -} - - -# YAML config handling via yq - -DB_CONFIG_PATH="${DB_CONFIG_PATH:-$HOME/.devboost.yaml}" - -db_yaml_get() { - local path="$1" default="${2-}" - if [[ -f "$DB_CONFIG_PATH" ]]; then - if db_command_exists yq; then - local result - result=$(yq "$path" "$DB_CONFIG_PATH" 2>/dev/null || echo "$default") - # Handle null values from yq - if [[ "$result" == "null" ]] || [[ -z "$result" ]]; then - echo "$default" - else - echo "$result" - fi - elif db_command_exists python3 && python3 -c "import yaml" 2>/dev/null; then - python3 -c " -import yaml -import sys -try: - with open('$DB_CONFIG_PATH', 'r') as f: - data = yaml.safe_load(f) or {} - def get_nested(d, keys): - for k in keys.split('.'): - if isinstance(d, dict) and k in d: - d = d[k] - else: - return None - return d - result = get_nested(data, '$path') - print(result if result is not None else '$default') -except: - print('$default') -" - else - echo "$default" - fi - else - echo "$default" - fi -} - -db_yaml_get_list() { - local path="$1" - if [[ -f "$DB_CONFIG_PATH" ]]; then - if db_command_exists yq; then - yq -e "$path[]" "$DB_CONFIG_PATH" 2>/dev/null | tr '\n' ' ' || echo "" - else - echo "" - fi - else - echo "" - fi -} - - -# File manipulation helpers - -db_ensure_dir() { - local dir="$1" - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would create directory: $dir" - return 0 - fi - mkdir -p "$dir" -} - -db_backup_file() { - local file="$1" - if [[ ! -f "$file" ]]; then - return 0 - fi - - local backup_dir="${DB_BACKUP_DIR:-$HOME/.devboost/backups}" - local timestamp=$(date +%Y%m%d_%H%M%S) - local backup_path="${backup_dir}/${timestamp}" - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would backup: $file -> ${backup_path}/$(basename "$file")" - return 0 - fi - - db_ensure_dir "$backup_path" - cp "$file" "${backup_path}/$(basename "$file")" - db_log_verbose "Backed up: $file" -} - -db_write_file() { - local file="$1" - local content="$2" - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would write: $file" - echo "$content" - else - db_backup_file "$file" - echo "$content" > "$file" - db_log_success "Wrote: $file" - fi -} - -db_upsert_block() { - local file="$1" - local start_marker="$2" - local end_marker="$3" - local new_content="$4" - - if [[ ! -f "$file" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would create: $file" - else - echo "$new_content" > "$file" - db_log_success "Created: $file" - fi - return 0 - fi - - if grep -q "$start_marker" "$file" 2>/dev/null; then - # Replace existing block - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would replace block in: $file" - else - db_backup_file "$file" - local temp_file=$(mktemp) - local block_file=$(mktemp) - echo "$new_content" > "$block_file" - awk -v start="$start_marker" -v end="$end_marker" -v block_file="$block_file" ' - $0 ~ start { - in_block=1 - while ((getline line < block_file) > 0) { - print line - } - close(block_file) - next - } - $0 ~ end { - in_block=0 - next - } - !in_block { print } - ' "$file" > "$temp_file" - mv "$temp_file" "$file" - rm -f "$block_file" - db_log_success "Updated block in: $file" - fi - else - # Append block - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would append block to: $file" - else - db_backup_file "$file" - echo "" >> "$file" - echo "$new_content" >> "$file" - db_log_success "Injected block into: $file" - fi - fi -} - -db_confirm() { - local prompt="$1" - local default="${2:-y}" - local hint - if [[ "$default" == "y" ]]; then - hint="[Y/n]" - else - hint="[y/N]" - fi - local reply - read -r -p "$(echo -e "${YELLOW}?${NC} ${prompt} ${hint} ")" reply - reply="${reply:-$default}" - reply=$(echo "$reply" | tr '[:upper:]' '[:lower:]') - [[ "$reply" == "y" ]] -} - -db_remove_block() { - local file="$1" - local start_marker="$2" - local end_marker="$3" - - if [[ ! -f "$file" ]] || ! grep -q "$start_marker" "$file" 2>/dev/null; then - return 0 - fi - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove block from: $file" - else - db_backup_file "$file" - local temp_file=$(mktemp) - awk -v start="$start_marker" -v end="$end_marker" ' - $0 ~ start { in_block=1; next } - $0 ~ end { in_block=0; next } - !in_block { print } - ' "$file" > "$temp_file" - mv "$temp_file" "$file" - db_log_success "Removed block from: $file" - fi -} - - -# oh-my-zsh removal + migration helper -# -# This command replicates oh-my-zsh's own tools/uninstall.sh (removes -# ~/.oh-my-zsh, renames the current ~/.zshrc to -# ~/.zshrc.omz-uninstalled-, and restores ~/.zshrc.pre-oh-my-zsh -# if that pre-install snapshot exists) and then recovers any customization -# the user made *after* installing oh-my-zsh (aliases, PATH tweaks, exports) -# that would otherwise be stranded in the timestamped backup. -# -# Because the restore step always makes ~/.zshrc identical to the pre-install -# base (when a base exists), recovery reduces to: lines present in the -# timestamped backup but absent from the base are the user's additions — -# append them, after stripping oh-my-zsh's own template lines (ZSH_THEME, -# plugins=, source $ZSH/oh-my-zsh.sh, etc., which differ from the base too -# but aren't user content). There's no real 3-way conflict to resolve here -# since "current" and "base" always match going in. -# -# This is its own explicit subcommand — separate from apply/plan/doctor/ -# uninstall — precisely because it removes a directory and rewrites .zshrc; -# it never runs as a side effect of anything else, and requires --yes. - -# Lines matching these patterns are oh-my-zsh's own template scaffolding, -# not user content, even though their values are user-customized (e.g. -# ZSH_THEME). Matched against templates/zshrc.zsh-template upstream. -_db_omz_is_template_line() { - local line="$1" - case "$line" in - '#'*) return 0 ;; - '') return 0 ;; - 'export ZSH='*) return 0 ;; - 'ZSH_THEME='*) return 0 ;; - 'ZSH_THEME_RANDOM_CANDIDATES='*) return 0 ;; - 'CASE_SENSITIVE='*) return 0 ;; - 'HYPHEN_INSENSITIVE='*) return 0 ;; - 'DISABLE_MAGIC_FUNCTIONS='*) return 0 ;; - 'DISABLE_LS_COLORS='*) return 0 ;; - 'DISABLE_AUTO_TITLE='*) return 0 ;; - 'ENABLE_CORRECTION='*) return 0 ;; - 'COMPLETION_WAITING_DOTS='*) return 0 ;; - 'DISABLE_UNTRACKED_FILES_DIRTY='*) return 0 ;; - 'HIST_STAMPS='*) return 0 ;; - 'ZSH_CUSTOM='*) return 0 ;; - 'zstyle '*':omz:'*) return 0 ;; - 'plugins=('*) return 0 ;; - 'source $ZSH/oh-my-zsh.sh'*) return 0 ;; - *) return 1 ;; - esac -} - -# Strip oh-my-zsh template lines from a file, writing the remainder to stdout. -_db_omz_strip_template() { - local file="$1" - local line - while IFS= read -r line || [[ -n "$line" ]]; do - _db_omz_is_template_line "$line" || echo "$line" - done < "$file" -} - -# Finds the most recently modified ~/.zshrc.omz-uninstalled-* backup, if any. -_db_omz_find_uninstalled_backup() { - local candidate - candidate=$(ls -t "$HOME"/.zshrc.omz-uninstalled-* 2>/dev/null | head -1) || true - echo "$candidate" -} - -# Replicates oh-my-zsh's tools/uninstall.sh: remove ~/.oh-my-zsh, rename the -# current ~/.zshrc to a timestamped backup, and restore ~/.zshrc.pre-oh-my-zsh -# if that pre-install snapshot exists. -_db_omz_uninstall() { - local omz_dir="$HOME/.oh-my-zsh" - local zshrc="$HOME/.zshrc" - local base="$HOME/.zshrc.pre-oh-my-zsh" - - if [[ ! -d "$omz_dir" ]]; then - db_log_verbose "No ~/.oh-my-zsh found — already removed or never installed." - return 0 - fi - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $omz_dir" - [[ -f "$zshrc" ]] && db_log_info "Would rename $zshrc to a timestamped .omz-uninstalled-* backup" - [[ -f "$base" ]] && db_log_info "Would restore $base to $zshrc" - return 0 - fi - - rm -rf "$omz_dir" - db_log_success "Removed: $omz_dir" - - if [[ -f "$zshrc" ]]; then - local saved="$HOME/.zshrc.omz-uninstalled-$(date +%Y-%m-%d_%H-%M-%S)" - mv "$zshrc" "$saved" - db_log_info "Renamed $zshrc to: $saved" - fi - - if [[ -f "$base" ]]; then - mv "$base" "$zshrc" - db_log_success "Restored pre-oh-my-zsh config to: $zshrc" - else - db_log_info "No ~/.zshrc.pre-oh-my-zsh found — nothing to restore." - fi -} - -# Writes to stdout the lines in $other that are not present anywhere in $base, -# after stripping oh-my-zsh template lines from $other. Order-preserving, -# duplicate-preserving (does not dedupe repeated lines within $other itself). -_db_omz_lines_only_in() { - local other="$1" base="$2" - local stripped_other - stripped_other=$(mktemp) - _db_omz_strip_template "$other" > "$stripped_other" - - if [[ -n "$base" ]] && [[ -f "$base" ]]; then - grep -vFxf "$base" "$stripped_other" 2>/dev/null || true - else - cat "$stripped_other" - fi - rm -f "$stripped_other" -} - -db_run_migrate_from_oh_my_zsh() { - local zshrc="$HOME/.zshrc" - local base="$HOME/.zshrc.pre-oh-my-zsh" - - if [[ "${DB_DRY_RUN:-false}" != "true" ]] && [[ "${DB_OMZ_YES:-false}" != "true" ]]; then - db_log_error "This removes ~/.oh-my-zsh and rewrites ~/.zshrc — pass --yes to confirm." - db_log_info "Preview first with: devboost migrate-from-oh-my-zsh --dry-run" - return 1 - fi - - # _db_omz_uninstall moves $base to $zshrc (replicating oh-my-zsh's own - # uninstaller), consuming it — so snapshot its content first, since we - # still need it below to tell the base's own lines apart from the user's - # genuine post-install additions in the timestamped backup. - local base_snapshot="" - if [[ -f "$base" ]] && [[ "${DB_DRY_RUN:-false}" != "true" ]]; then - base_snapshot=$(mktemp) - cp "$base" "$base_snapshot" - fi - - _db_omz_uninstall - - local uninstalled - uninstalled=$(_db_omz_find_uninstalled_backup) - - if [[ -z "$uninstalled" ]]; then - [[ -n "$base_snapshot" ]] && rm -f "$base_snapshot" - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "No existing ~/.zshrc.omz-uninstalled-* backup yet — nothing further to recover in a dry-run." - return 0 - fi - db_log_error "No ~/.zshrc.omz-uninstalled-* backup found — nothing to recover." - return 1 - fi - db_log_info "Found uninstall backup: $uninstalled" - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - if [[ -f "$base" ]]; then - db_log_info "Would recover your additions from '$(basename "$uninstalled")' not already in '$(basename "$base")'" - else - db_log_info "Would recover your additions from '$(basename "$uninstalled")' (no pre-install base to compare against)" - fi - db_log_info "Would append them to: $zshrc" - return 0 - fi - - local additions - additions=$(_db_omz_lines_only_in "$uninstalled" "$base_snapshot") - rm -f "$base_snapshot" - - if [[ -z "$additions" ]]; then - db_log_success "No post-install customizations found beyond oh-my-zsh's own template — nothing to recover." - db_log_info "Review $zshrc, then run 'devboost apply' to add devboost's include block." - return 0 - fi - - db_backup_file "$zshrc" - { - if [[ -f "$zshrc" ]]; then - cat "$zshrc" - echo "" - fi - echo "$additions" - } > "${zshrc}.devboost-omz-tmp" - mv "${zshrc}.devboost-omz-tmp" "$zshrc" - - db_log_success "Recovered your customizations into: $zshrc" - db_log_info "Review the result, then run 'devboost apply' to add devboost's include block." - return 0 -} - -# Module registry system -# Uses bash 3.x compatible approach (no associative arrays) - -DB_MODULE_NAMES=() - -# Helper functions for bash 3.x compatibility (simulating associative arrays) -_db_module_set() { - local var="$1" key="$2" value="$3" - # Sanitize key to be a valid variable name - key=$(echo "$key" | tr -cd '[:alnum:]_') - eval "${var}_${key}=\"\$value\"" -} - -_db_module_get() { - local var="$1" key="$2" - # Sanitize key to be a valid variable name - key=$(echo "$key" | tr -cd '[:alnum:]_') - eval "echo \"\${${var}_${key}:-}\"" -} - -db_register_module() { - local name="$1" plan="$2" apply="$3" doctor="${4:-}" - DB_MODULE_NAMES+=("$name") - _db_module_set "DB_MODULE_PLAN_FUNC" "$name" "$plan" - _db_module_set "DB_MODULE_APPLY_FUNC" "$name" "$apply" - if [[ -n "$doctor" ]]; then - _db_module_set "DB_MODULE_DOCTOR_FUNC" "$name" "$doctor" - fi - db_log_verbose "Registered module: $name" -} - -# db_load_modules() is defined after all modules are loaded (in build output) - -db_run_plan() { - db_log_info "Planning changes..." - for m in "${DB_MODULE_NAMES[@]}"; do - db_log_verbose "Planning module: $m" - local func=$(_db_module_get "DB_MODULE_PLAN_FUNC" "$m") - [[ -n "$func" ]] && "$func" || true - done -} - -db_run_apply() { - db_log_info "Applying configuration..." - for m in "${DB_MODULE_NAMES[@]}"; do - db_log_verbose "Applying module: $m" - local func=$(_db_module_get "DB_MODULE_APPLY_FUNC" "$m") - [[ -n "$func" ]] && "$func" || true - done -} - -db_run_doctor() { - db_log_info "Running diagnostics..." - for m in "${DB_MODULE_NAMES[@]}"; do - local func=$(_db_module_get "DB_MODULE_DOCTOR_FUNC" "$m") - if [[ -n "$func" ]]; then - db_log_verbose "Checking module: $m" - "$func" || true - fi - done -} - - -# Main entry point and CLI - -DB_VERSION="1.3.0" -DB_SUBCOMMAND="apply" -DB_DRY_RUN=false -DB_VERBOSE=false -DB_OMZ_YES=false -DB_BACKUP_DIR="${HOME}/.devboost/backups" -DB_STATE_FILE="${HOME}/.devboost.state.json" - -db_parse_flags() { - while [[ $# -gt 0 ]]; do - case $1 in - apply|plan|doctor|uninstall|migrate-from-oh-my-zsh) - DB_SUBCOMMAND="$1" - shift - ;; - --config) - DB_CONFIG_PATH="$2" - shift 2 - ;; - --dry-run) - DB_DRY_RUN=true - shift - ;; - --yes) - DB_OMZ_YES=true - shift - ;; - --verbose|-v) - DB_VERBOSE=true - shift - ;; - --help|-h) - db_show_help - exit 0 - ;; - --version) - echo "devboost $DB_VERSION" - exit 0 - ;; - *) - db_log_error "Unknown option: $1" - db_show_help - exit 1 - ;; - esac - done -} - -db_show_help() { - cat << EOF -devboost - Bootstrap a modern dev environment - -Usage: devboost [COMMAND] [OPTIONS] - -Commands: - apply Converge machine to config (default) - plan Show actions without changing anything - doctor Check prerequisites, PATHs, shells, conflicting files - uninstall Remove managed files/blocks (leaves user custom files untouched) - migrate-from-oh-my-zsh Remove oh-my-zsh and recover .zshrc customizations (needs --yes) - -Options: - --config FILE Config file path (default: ~/.devboost.yaml) - --dry-run Show what would be done without making changes - --yes Confirm a destructive command (required by migrate-from-oh-my-zsh) - --verbose, -v Enable verbose output - --help, -h Show this help message - --version Show version - -EOF -} - -coreMain() { - # Parse command first (before flags) - local cmd="apply" - if [[ $# -gt 0 ]] && [[ "$1" =~ ^(apply|plan|doctor|uninstall|migrate-from-oh-my-zsh)$ ]]; then - cmd="$1" - shift - fi - - db_parse_flags "$@" - - # Override subcommand if it was set via flags (shouldn't happen, but just in case) - DB_SUBCOMMAND="$cmd" - - db_log_info "devboost $DB_VERSION - $DB_SUBCOMMAND" - - # Check config version compatibility - local config_version=$(db_yaml_get '.version' '') - if [[ -n "$config_version" ]]; then - local config_major=$(echo "$config_version" | cut -d. -f1) - local script_major=$(echo "$DB_VERSION" | cut -d. -f1) - - if [[ "$config_major" -lt "$script_major" ]]; then - db_log_warn "Config file version ($config_version) is older than script version ($DB_VERSION)" - db_log_warn "Please review CHANGELOG.md for breaking changes" - fi - fi - - # Ensure backup directory exists - db_ensure_dir "$DB_BACKUP_DIR" - - # Detect OS - db_detect_os - - # Load modules (already sourced, just register) - db_load_modules - - case "$DB_SUBCOMMAND" in - apply) - db_run_apply - db_log_success "Configuration applied!" - ;; - plan) - DB_DRY_RUN=true - db_run_plan - db_log_info "Plan complete" - ;; - doctor) - db_run_doctor - # Also run general diagnostics - db_log_info "OS: $DB_OS" - db_log_info "Current shell: $SHELL" - db_command_exists zsh && db_log_success "zsh: found" || db_log_error "zsh: not found" - db_command_exists git && db_log_success "git: found" || db_log_error "git: not found" - db_command_exists curl && db_log_success "curl: found" || db_log_error "curl: not found" - ;; - uninstall) - db_run_uninstall - ;; - migrate-from-oh-my-zsh) - db_run_migrate_from_oh_my_zsh - ;; - *) - db_die "Unknown command: $DB_SUBCOMMAND" - ;; - esac -} - -db_run_uninstall() { - db_log_warn "Uninstalling devboost managed files..." - - # Remove .zshrc.devboost - local include_file=$(db_yaml_get '.zsh.include_file' "$HOME/.zshrc.devboost") - if [[ -f "$include_file" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $include_file" - else - rm -f "$include_file" - db_log_success "Removed: $include_file" - fi - fi - - # Remove devboost block from .zshrc - local zshrc="${HOME}/.zshrc" - if [[ -f "$zshrc" ]] && grep -q "# >>> devboost include start" "$zshrc" 2>/dev/null; then - db_remove_block "$zshrc" "# >>> devboost include start" "# <<< devboost include end" - fi - - # Remove devboost block from .tmux.conf - local tmux_conf=$(db_yaml_get '.tmux.conf_file' "$HOME/.tmux.conf") - if [[ -f "$tmux_conf" ]] && grep -q "# >>> devboost tmux start" "$tmux_conf" 2>/dev/null; then - db_remove_block "$tmux_conf" "# >>> devboost tmux start" "# <<< devboost tmux end" - fi - - # Remove direnvrc - local direnvrc=$(db_yaml_get '.direnv.rc_path' "$HOME/.direnvrc") - if [[ -f "$direnvrc" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $direnvrc" - else - db_backup_file "$direnvrc" - rm -f "$direnvrc" - db_log_success "Removed: $direnvrc" - fi - fi - - # Remove state file - if [[ -f "$DB_STATE_FILE" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would remove: $DB_STATE_FILE" - else - rm -f "$DB_STATE_FILE" - db_log_success "Removed: $DB_STATE_FILE" - fi - fi - - db_log_info "Uninstall complete. Backups are preserved in: $DB_BACKUP_DIR" - db_log_info "Note: Packages, znap, TPM, and mise toolchains are not removed." -} - - -# === Modules === -# Package installation module - -db_module_pkg_register() { - db_register_module "pkg" \ - "db_module_pkg_plan" \ - "db_module_pkg_apply" -} - -db_module_pkg_plan() { - local base_pkgs=$(db_yaml_get_list '.packages.base[]') - if [[ -n "$base_pkgs" ]]; then - db_log_info "Would install packages: $base_pkgs" - fi -} - -# Helper function to map package names (bash 3.x compatible) -_db_pkg_map() { - local os="$1" pkg="$2" - case "$os" in - darwin) - case "$pkg" in - zsh) echo "zsh" ;; - zoxide) echo "zoxide" ;; - fzf) echo "fzf" ;; - ripgrep) echo "ripgrep" ;; - fd) echo "fd" ;; - bat) echo "bat" ;; - eza) echo "eza" ;; - jq) echo "jq" ;; - yq) echo "yq" ;; - git-delta) echo "git-delta" ;; - lazygit) echo "lazygit" ;; - direnv) echo "direnv" ;; - mise) echo "mise" ;; - atuin) echo "atuin" ;; - starship) echo "starship" ;; - tmux) echo "tmux" ;; - dust) echo "dust" ;; - duf) echo "duf" ;; - procs) echo "procs" ;; - *) echo "$pkg" ;; - esac - ;; - linux-ubuntu) - case "$pkg" in - zsh) echo "zsh" ;; - zoxide) echo "zoxide" ;; - fzf) echo "fzf" ;; - ripgrep) echo "ripgrep" ;; - fd) echo "fd-find" ;; - bat) echo "bat" ;; - eza) echo "eza" ;; - jq) echo "jq" ;; - yq) echo "yq" ;; - git-delta) echo "git-delta" ;; - lazygit) echo "lazygit" ;; - direnv) echo "direnv" ;; - mise) echo "mise" ;; - atuin) echo "atuin" ;; - starship) echo "starship" ;; - tmux) echo "tmux" ;; - dust) echo "dust" ;; - duf) echo "duf" ;; - procs) echo "procs" ;; - *) echo "$pkg" ;; - esac - ;; - linux-fedora) - case "$pkg" in - zsh) echo "zsh" ;; - zoxide) echo "zoxide" ;; - fzf) echo "fzf" ;; - ripgrep) echo "ripgrep" ;; - fd) echo "fd-find" ;; - bat) echo "bat" ;; - eza) echo "eza" ;; - jq) echo "jq" ;; - yq) echo "yq" ;; - git-delta) echo "git-delta" ;; - lazygit) echo "lazygit" ;; - direnv) echo "direnv" ;; - mise) echo "mise" ;; - atuin) echo "atuin" ;; - starship) echo "starship" ;; - tmux) echo "tmux" ;; - dust) echo "dust" ;; - duf) echo "duf" ;; - procs) echo "procs" ;; - *) echo "$pkg" ;; - esac - ;; - linux-arch) - case "$pkg" in - zsh) echo "zsh" ;; - zoxide) echo "zoxide" ;; - fzf) echo "fzf" ;; - ripgrep) echo "ripgrep" ;; - fd) echo "fd" ;; - bat) echo "bat" ;; - eza) echo "eza" ;; - jq) echo "jq" ;; - yq) echo "yq" ;; - git-delta) echo "git-delta" ;; - lazygit) echo "lazygit" ;; - direnv) echo "direnv" ;; - mise) echo "mise" ;; - atuin) echo "atuin" ;; - starship) echo "starship" ;; - tmux) echo "tmux" ;; - dust) echo "dust" ;; - duf) echo "duf" ;; - procs) echo "procs" ;; - *) echo "$pkg" ;; - esac - ;; - *) - echo "$pkg" - ;; - esac -} - -db_module_pkg_apply() { - # Get base packages from config - local base_pkgs_str=$(db_yaml_get_list '.packages.base[]') - if [[ -z "$base_pkgs_str" ]]; then - # Default packages - base_pkgs_str="zsh zoxide fzf ripgrep fd bat eza jq yq git-delta lazygit direnv mise atuin starship tmux dust duf procs" - fi - - # Convert to array - read -ra base_pkgs <<< "$base_pkgs_str" - - # Map packages based on OS - local mapped_pkgs=() - for pkg in "${base_pkgs[@]}"; do - mapped_pkgs+=("$(_db_pkg_map "$DB_OS" "$pkg")") - done - - db_install_packages "${mapped_pkgs[@]}" -} - - -# Znap (zsh plugin manager) module - -db_module_znap_register() { - db_register_module "znap" \ - "db_module_znap_plan" \ - "db_module_znap_apply" -} - -db_module_znap_plan() { - local znap_path=$(db_yaml_get '.zsh.znap_path' "$HOME/.zsh-snap") - if [[ ! -d "$znap_path" ]]; then - db_log_info "Would install znap to: $znap_path" - fi -} - -db_module_znap_apply() { - local znap_path=$(db_yaml_get '.zsh.znap_path' "$HOME/.zsh-snap") - local znap_git=$(db_yaml_get '.zsh.znap_git' "https://github.com/marlonrichert/zsh-snap.git") - - if [[ -d "$znap_path" ]]; then - db_log_verbose "Znap already installed at: $znap_path" - return 0 - fi - - db_log_info "Installing znap..." - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would clone znap to: $znap_path" - return 0 - fi - - db_ensure_dir "$(dirname "$znap_path")" - git clone --depth 1 "$znap_git" "$znap_path" || { - db_log_error "Failed to install znap" - return 1 - } - db_log_success "Installed znap" -} - - -# Zsh configuration module - -db_module_zsh_register() { - db_register_module "zsh" \ - "db_module_zsh_plan" \ - "db_module_zsh_apply" -} - -db_module_zsh_plan() { - local enable=$(db_yaml_get '.zsh.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local include_file=$(db_yaml_get '.zsh.include_file' "$HOME/.zshrc.devboost") - local zshrc="${HOME}/.zshrc" - - if [[ ! -f "$include_file" ]]; then - db_log_info "Would create: $include_file" - fi - - if [[ ! -f "$zshrc" ]]; then - db_log_info "Would inject devboost include block into: $zshrc" - elif ! grep -q "# >>> devboost include start" "$zshrc" 2>/dev/null; then - if grep -Eq '(^|[^#].*)\.zshrc\.devboost' "$zshrc" 2>/dev/null; then - db_log_warn "zsh: $zshrc already has an unmarked line sourcing .zshrc.devboost — apply will skip injecting to avoid double-sourcing" - else - db_log_info "Would inject devboost include block into: $zshrc" - fi - fi - - # Check for atuin config file - local use_atuin=$(db_yaml_get '.zsh.history.use_atuin' 'true') - if [[ "$use_atuin" == "true" ]]; then - local atuin_config="$HOME/.config/atuin/config.toml" - if [[ ! -f "$atuin_config" ]]; then - db_log_info "Would create atuin config: $atuin_config" - fi - fi -} - -db_render_atuin_config() { - # Default to 'directory' mode based on research: developers prefer - # context-specific history that shares within the same directory - # rather than global sharing (atuin's default) which can be overwhelming - local filter_mode=$(db_yaml_get '.zsh.history.atuin.filter_mode' 'directory') - - cat << EOF -# Generated by devboost - DO NOT EDIT MANUALLY -# Atuin filter mode: controls how command history is shared between shells -# Options: global, host, session, directory, workspace -# Default: directory (context-specific history for developers) -filter_mode = "${filter_mode}" -EOF -} - -db_render_zsh_devboost() { - local znap_path=$(db_yaml_get '.zsh.znap_path' "$HOME/.zsh-snap") - local enable_starship=$(db_yaml_get '.prompt.enable_starship' 'true') - local starship_config=$(db_yaml_get '.prompt.starship_config' "$HOME/.config/starship.toml") - local use_atuin=$(db_yaml_get '.zsh.history.use_atuin' 'true') - local fzf_enable=$(db_yaml_get '.zsh.fzf.enable' 'true') - local fzf_files=$(db_yaml_get '.zsh.fzf.default_command_files' 'fd --type f --hidden --follow --exclude .git') - local fzf_dirs=$(db_yaml_get '.zsh.fzf.default_command_dirs' 'fd --type d --hidden --follow --exclude .git') - local enable_mise=$(db_yaml_get '.toolchains.enable_mise' 'true') - local enable_direnv=$(db_yaml_get '.direnv.enable' 'true') - local clicolor=$(db_yaml_get '.aesthetics.clicolor' 'true') - local lsc_colours=$(db_yaml_get '.aesthetics.lsc_colours' 'ExFxCxDxBxegedabagacad') - local aliases_enable=$(db_yaml_get '.zsh.aliases.enable' 'true') - - cat << EOF -# Generated by devboost - DO NOT EDIT MANUALLY -export EDITOR="nvim" -export LANG="en_US.UTF-8" - -setopt HIST_IGNORE_ALL_DUPS HIST_REDUCE_BLANKS SHARE_HISTORY INC_APPEND_HISTORY -autoload -Uz compinit && compinit -u -setopt AUTO_CD NO_BEEP - -# znap -source "${znap_path}/znap.zsh" - -# prompt -EOF - - if [[ "$enable_starship" == "true" ]]; then - echo "export STARSHIP_CONFIG=\"${starship_config}\"" - echo 'eval "$(starship init zsh)"' - fi - - cat << 'EOF' - -# plugins -znap source zsh-users/zsh-autosuggestions -znap source zsh-users/zsh-syntax-highlighting - -# nav/search/history -eval "$(zoxide init zsh)" -EOF - - if [[ "$use_atuin" == "true" ]]; then - echo 'eval "$(atuin init zsh)"' - fi - - if [[ "$fzf_enable" == "true" ]]; then - echo 'eval "$(fzf --zsh 2>/dev/null || /opt/homebrew/bin/fzf --zsh 2>/dev/null || true)"' - echo "export FZF_DEFAULT_COMMAND='${fzf_files}'" - echo 'export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"' - echo "export FZF_ALT_C_COMMAND='${fzf_dirs}'" - fi - - cat << EOF - -# toolchains & per-project env -EOF - - if [[ "$enable_mise" == "true" ]]; then - echo 'eval "$(mise activate zsh)"' - fi - - if [[ "$enable_direnv" == "true" ]]; then - echo 'eval "$(direnv hook zsh)"' - fi - - cat << EOF - -# aesthetics -EOF - - if [[ "$clicolor" == "true" ]]; then - echo 'export CLICOLOR=1' - fi - - echo "export LSCOLORS=\"${lsc_colours}\"" - - cat << EOF - -# aliases -EOF - - if [[ "$aliases_enable" == "true" ]]; then - cat << EOF -alias ls='eza -alg --git --group --time-style=relative' -alias cat='bat -pp' -alias grep='rg' -alias find='fd' -alias du='dust' -alias df='duf' -alias ps='procs' -alias lg='lazygit' -alias tm='tmux attach -t main || tmux new -s main' -alias please='sudo $(fc -ln -1)' -EOF - fi -} - -db_module_zsh_apply() { - local enable=$(db_yaml_get '.zsh.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local include_file=$(db_yaml_get '.zsh.include_file' "$HOME/.zshrc.devboost") - local zshrc="${HOME}/.zshrc" - - # Generate and write .zshrc.devboost - local content=$(db_render_zsh_devboost) - db_write_file "$include_file" "$content" - - # Inject include block into .zshrc - local include_block="# >>> devboost include start -[ -f \"\$HOME/.zshrc.devboost\" ] && source \"\$HOME/.zshrc.devboost\" -# <<< devboost include end -" - - if [[ ! -f "$zshrc" ]]; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would create: $zshrc" - else - echo "$include_block" > "$zshrc" - db_log_success "Created: $zshrc" - fi - elif grep -q "# >>> devboost include start" "$zshrc" 2>/dev/null; then - db_log_verbose "Include block already present in: $zshrc" - elif grep -Eq '(^|[^#].*)\.zshrc\.devboost' "$zshrc" 2>/dev/null; then - # An unmarked line already sources .zshrc.devboost — likely left over from a - # prior manual edit or recovery (e.g. 'devboost migrate-from-oh-my-zsh'). - # Appending our own marked block on top would source it twice on every - # shell start. Warn instead of duplicating; the user can remove the old - # line and re-run apply, or we'd need to know which line is "ours" to - # safely replace it, which we can't tell from content alone. - db_log_warn "zsh: $zshrc already has an unmarked line sourcing .zshrc.devboost — skipping to avoid double-sourcing it." - db_log_warn "zsh: remove that line (see: grep -n zshrc.devboost $zshrc) and re-run 'devboost apply' to add the managed include block." - else - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would append include block to: $zshrc" - else - db_backup_file "$zshrc" - echo "" >> "$zshrc" - echo "$include_block" >> "$zshrc" - db_log_success "Injected include block into: $zshrc" - fi - fi - - # Create atuin config file if atuin is enabled - local use_atuin=$(db_yaml_get '.zsh.history.use_atuin' 'true') - if [[ "$use_atuin" == "true" ]]; then - local atuin_config="$HOME/.config/atuin/config.toml" - local config_dir=$(dirname "$atuin_config") - - db_ensure_dir "$config_dir" - - local atuin_content=$(db_render_atuin_config) - db_write_file "$atuin_config" "$atuin_content" - fi -} - - -# Starship prompt module - -db_module_starship_register() { - db_register_module "starship" \ - "db_module_starship_plan" \ - "db_module_starship_apply" -} - -db_module_starship_plan() { - local enable=$(db_yaml_get '.prompt.enable_starship' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local starship_config=$(db_yaml_get '.prompt.starship_config' "$HOME/.config/starship.toml") - if [[ ! -f "$starship_config" ]]; then - db_log_info "Would create starship config: $starship_config" - fi -} - -db_render_starship_config() { - cat << 'EOF' -add_newline = false -command_timeout = 700 - -[character] -success_symbol = "[❯](bold green)" -error_symbol = "[❯](bold red)" - -[directory] -truncation_length = 3 -style = "bold blue" - -[git_branch] -symbol = "" -style = "bold yellow" - -[git_status] -style = "bold red" -format = '([\[$all_status\]]($style))' - -[nodejs] -symbol = "" -style = "green" - -[python] -symbol = "" -style = "yellow" - -[rust] -symbol = "" -style = "red" - -[package] -disabled = true -EOF -} - -db_module_starship_apply() { - local enable=$(db_yaml_get '.prompt.enable_starship' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local starship_config=$(db_yaml_get '.prompt.starship_config' "$HOME/.config/starship.toml") - local config_dir=$(dirname "$starship_config") - - db_ensure_dir "$config_dir" - - local content=$(db_render_starship_config) - db_write_file "$starship_config" "$content" -} - - -# Tmux configuration module - -db_module_tmux_register() { - db_register_module "tmux" \ - "db_module_tmux_plan" \ - "db_module_tmux_apply" -} - -db_module_tmux_plan() { - local enable=$(db_yaml_get '.tmux.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local tpm_path=$(db_yaml_get '.tmux.tpm_path' "$HOME/.tmux/plugins/tpm") - local conf_file=$(db_yaml_get '.tmux.conf_file' "$HOME/.tmux.conf") - - if [[ ! -d "$tpm_path" ]]; then - db_log_info "Would install TPM to: $tpm_path" - fi - - if [[ ! -f "$conf_file" ]] || ! grep -q "# >>> devboost tmux start" "$conf_file" 2>/dev/null; then - db_log_info "Would inject tmux config block into: $conf_file" - fi -} - -db_render_tmux_block() { - local tpm_path=$(db_yaml_get '.tmux.tpm_path' "$HOME/.tmux/plugins/tpm") - local base_index=$(db_yaml_get '.tmux.settings.base_index' '1') - local pane_base_index=$(db_yaml_get '.tmux.settings.pane_base_index' '1') - local mouse=$(db_yaml_get '.tmux.settings.mouse' 'true') - local history_limit=$(db_yaml_get '.tmux.settings.history_limit' '50000') - local escape_time=$(db_yaml_get '.tmux.settings.escape_time' '0') - local focus_events=$(db_yaml_get '.tmux.settings.focus_events' 'true') - local continuum_restore=$(db_yaml_get '.tmux.settings.continuum_restore' 'true') - local resurrect_capture=$(db_yaml_get '.tmux.settings.resurrect_capture_pane_contents' 'true') - - local mouse_val="on" - [[ "$mouse" != "true" ]] && mouse_val="off" - local focus_val="on" - [[ "$focus_events" != "true" ]] && focus_val="off" - local continuum_val="on" - [[ "$continuum_restore" != "true" ]] && continuum_val="off" - local resurrect_val="on" - [[ "$resurrect_capture" != "true" ]] && resurrect_val="off" - - cat << EOF -# >>> devboost tmux start -set -g base-index ${base_index} -setw -g pane-base-index ${pane_base_index} -set -g mouse ${mouse_val} -set -g history-limit ${history_limit} -set -s escape-time ${escape_time} -set -g focus-events ${focus_val} -set -g @plugin 'tmux-plugins/tpm' -set -g @plugin 'tmux-plugins/tmux-resurrect' -set -g @plugin 'tmux-plugins/tmux-continuum' -set -g @plugin 'tmux-plugins/tmux-yank' -set -g @plugin 'tmux-plugins/tmux-logging' -set -g @continuum-restore '${continuum_val}' -set -g @resurrect-capture-pane-contents '${resurrect_val}' -run '${tpm_path}/tpm' -# <<< devboost tmux end -EOF -} - -db_module_tmux_apply() { - local enable=$(db_yaml_get '.tmux.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local conf_file=$(db_yaml_get '.tmux.conf_file' "$HOME/.tmux.conf") - local tpm_path=$(db_yaml_get '.tmux.tpm_path' "$HOME/.tmux/plugins/tpm") - - # Install TPM - if [[ ! -d "$tpm_path" ]]; then - db_log_info "Installing TPM..." - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would clone TPM to: $tpm_path" - else - db_ensure_dir "$(dirname "$tpm_path")" - git clone https://github.com/tmux-plugins/tpm "$tpm_path" || { - db_log_error "Failed to install TPM" - return 1 - } - db_log_success "Installed TPM" - fi - fi - - # Generate and inject tmux config block - local block=$(db_render_tmux_block) - db_upsert_block "$conf_file" "# >>> devboost tmux start" "# <<< devboost tmux end" "$block" - - # Install/update plugins (only if not dry-run and tmux is available) - if [[ "${DB_DRY_RUN:-false}" != "true" ]] && db_command_exists tmux; then - local auto_install=$(db_yaml_get '.system.auto_install_plugins' 'true') - if [[ "$auto_install" == "true" ]]; then - db_log_info "Installing tmux plugins via CLI..." - "$tpm_path/bindings/install_plugins" &>/dev/null || true - "$tpm_path/bindings/update_plugins" all &>/dev/null || true - else - db_log_info "Tmux plugins will install on next tmux session (run 'prefix + I' in tmux)" - fi - fi -} - - -# Mise (toolchain manager) module - -db_module_mise_register() { - db_register_module "mise" \ - "db_module_mise_plan" \ - "db_module_mise_apply" -} - -db_module_mise_plan() { - local enable=$(db_yaml_get '.toolchains.enable_mise' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - db_log_info "Would configure mise toolchains" -} - -# Returns a space-separated list of globally installed npm packages (excluding npm itself). -_db_mise_npm_globals() { - local node_bin - node_bin=$(command -v node 2>/dev/null) || return 0 - local npm_bin - npm_bin=$(dirname "$node_bin")/npm - [[ -x "$npm_bin" ]] || return 0 - "$npm_bin" list -g --depth=0 --parseable 2>/dev/null \ - | awk -F/ 'NF>1 && $NF!="npm" && $NF!="corepack" {print $NF}' -} - -# Returns the currently active mise-managed node version, or empty string. -_db_mise_current_node_version() { - mise current node 2>/dev/null | tr -d '[:space:]' || true -} - -db_module_mise_apply() { - local enable=$(db_yaml_get '.toolchains.enable_mise' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - if ! db_command_exists mise; then - db_log_warn "mise not found, skipping toolchain setup" - return 0 - fi - - db_log_info "Configuring mise toolchains..." - - local node=$(db_yaml_get '.toolchains.globals.node' 'lts') - local python=$(db_yaml_get '.toolchains.globals.python' '3.14') - local go=$(db_yaml_get '.toolchains.globals.go' '1.26') - local rust=$(db_yaml_get '.toolchains.globals.rust' 'stable') - local deno=$(db_yaml_get '.toolchains.globals.deno' 'lts') - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would run: mise use -g node@${node} python@${python} go@${go} rust@${rust} deno@${deno}" - db_log_info "Would run: mise install" - return 0 - fi - - # Capture current node version and its global npm packages before any upgrade. - local prev_node_version - prev_node_version=$(_db_mise_current_node_version) - local npm_globals=() - if [[ -n "$prev_node_version" ]] && db_command_exists node; then - while IFS= read -r pkg; do - [[ -n "$pkg" ]] && npm_globals+=("$pkg") - done < <(_db_mise_npm_globals) - fi - - mise use -g "node@${node}" "python@${python}" "go@${go}" "rust@${rust}" "deno@${deno}" 2>/dev/null || true - mise install 2>/dev/null || db_log_warn "Some toolchains may not be available" - db_log_success "Configured mise toolchains" - - # After upgrade, offer to migrate npm globals to the new node version. - if [[ ${#npm_globals[@]} -gt 0 ]]; then - local new_node_version - new_node_version=$(_db_mise_current_node_version) - if [[ "$prev_node_version" != "$new_node_version" ]]; then - db_log_warn "Node upgraded: ${prev_node_version} → ${new_node_version}" - db_log_warn "The following global npm packages were present in the old version:" - for pkg in "${npm_globals[@]}"; do - db_log_warn " - $pkg" - done - db_log_warn "Note: any version pins or custom configuration for these packages will NOT be migrated." - if db_confirm "Reinstall these packages into node@${new_node_version}?"; then - local npm_bin - npm_bin=$(dirname "$(command -v node)")/npm - for pkg in "${npm_globals[@]}"; do - db_log_info "Installing $pkg..." - "$npm_bin" install -g "$pkg" 2>/dev/null \ - && db_log_success " ✓ $pkg" \ - || db_log_warn " ✗ $pkg (failed — install manually if needed)" - done - fi - fi - fi -} - -# Corepack module — enables pnpm/yarn shims bundled with Node.js - -db_module_corepack_register() { - db_register_module "corepack" \ - "db_module_corepack_plan" \ - "db_module_corepack_apply" -} - -db_module_corepack_plan() { - local enable=$(db_yaml_get '.toolchains.enable_mise' 'true') - [[ "$enable" == "true" ]] || return 0 - db_log_info "Would run: corepack enable" -} - -db_module_corepack_apply() { - local enable=$(db_yaml_get '.toolchains.enable_mise' 'true') - [[ "$enable" == "true" ]] || return 0 - - if ! db_command_exists corepack; then - db_log_warn "corepack not found (expected inside Node.js install), skipping" - return 0 - fi - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would run: corepack enable" - return 0 - fi - - corepack enable 2>/dev/null && db_log_success "corepack: pnpm/yarn shims enabled" \ - || db_log_warn "corepack enable failed — pnpm/yarn shims may be missing" -} - -# Direnv module - -db_module_direnv_register() { - db_register_module "direnv" \ - "db_module_direnv_plan" \ - "db_module_direnv_apply" -} - -db_module_direnv_plan() { - local enable=$(db_yaml_get '.direnv.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local rc_path=$(db_yaml_get '.direnv.rc_path' "$HOME/.direnvrc") - if [[ ! -f "$rc_path" ]]; then - db_log_info "Would create: $rc_path" - fi -} - -db_module_direnv_apply() { - local enable=$(db_yaml_get '.direnv.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - local rc_path=$(db_yaml_get '.direnv.rc_path' "$HOME/.direnvrc") - local content=$(db_yaml_get '.direnv.content' '') - - # Use default if content is empty - if [[ -z "$content" ]]; then - content="use_mise() { eval \"\$(mise activate direnv)\"; }" - fi - - db_write_file "$rc_path" "$content" -} - - -# Git delta configuration module - -db_module_git_register() { - db_register_module "git" \ - "db_module_git_plan" \ - "db_module_git_apply" -} - -db_module_git_plan() { - local enable=$(db_yaml_get '.git.delta.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - db_log_info "Would configure git delta" -} - -db_module_git_apply() { - local enable=$(db_yaml_get '.git.delta.enable' 'true') - if [[ "$enable" != "true" ]]; then - return 0 - fi - - if ! db_command_exists git; then - db_log_warn "git not found, skipping delta config" - return 0 - fi - - db_log_info "Configuring git delta..." - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would set git config delta settings" - else - git config --global core.pager delta || true - git config --global interactive.diffFilter 'delta --color-only' || true - git config --global delta.navigate "$(db_yaml_get '.git.delta.navigate' 'true')" || true - git config --global delta.line-numbers "$(db_yaml_get '.git.delta.line_numbers' 'true')" || true - db_log_success "Configured git delta" - fi -} - - -# Services module (atuin daemon, etc.) - -db_module_services_register() { - db_register_module "services" \ - "db_module_services_plan" \ - "db_module_services_apply" -} - -db_module_services_plan() { - local use_atuin=$(db_yaml_get '.zsh.history.use_atuin' 'true') - if [[ "$use_atuin" != "true" ]]; then - return 0 - fi - - db_log_info "Would start atuin daemon" -} - -db_module_services_apply() { - local use_atuin=$(db_yaml_get '.zsh.history.use_atuin' 'true') - if [[ "$use_atuin" != "true" ]]; then - return 0 - fi - - if ! db_command_exists atuin; then - db_log_warn "atuin not found, skipping service setup" - return 0 - fi - - if [[ "$DB_OS" == "darwin" ]]; then - if db_command_exists brew; then - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would start atuin service via brew" - else - # Check if service is already running to avoid output message - if brew services list 2>/dev/null | grep -q "^atuin.*started"; then - db_log_verbose "Atuin service already running" - else - brew services start atuin >/dev/null 2>&1 || db_log_verbose "Failed to start atuin service" - fi - fi - fi - else - # Linux - suggest systemd or manual start - db_log_info "On Linux, ensure atuin daemon is running (systemd user service or manual start)" - fi -} - - -# Security hygiene module -# -# Goals: -# - Surface when tools are significantly out of date (LLMs regularly find 0-days in -# widely-used packages; supply chain poisoning targets old pinned deps) -# - Encourage a "stable but not bleeding-edge" update cadence -# - Warn when config uses 'latest' for toolchains whose supply chains are higher risk -# - Provide a convenient update alias so users can act on warnings without hunting commands -# -# Non-goals: -# - Auto-updating anything without explicit user action -# - Blocking apply/plan on security findings (advisory only) - -db_module_security_register() { - db_register_module "security" \ - "db_module_security_plan" \ - "db_module_security_apply" \ - "db_module_security_doctor" -} - -db_module_security_plan() { - local enable=$(db_yaml_get '.security.enable' 'true') - [[ "$enable" != "true" ]] && return 0 - db_log_info "Would add update-check alias and security guidance to shell config" -} - -db_module_security_apply() { - local enable=$(db_yaml_get '.security.enable' 'true') - [[ "$enable" != "true" ]] && return 0 - - local include_file=$(db_yaml_get '.zsh.include_file' "$HOME/.zshrc.devboost") - - # Inject a db_security block with a 'devboost-check' alias that reminds the user - # to run update checks. This is additive — it only sets an alias and a nag function. - local block - block=$(cat << 'EOF' -# >>> devboost security start -# Run 'devboost-check' at any time to see a summary of outdated tools. -devboost-check() { - echo "=== devboost security check ===" - local issues=0 - - # Homebrew outdated - if command -v brew &>/dev/null; then - local outdated - outdated=$(brew outdated --quiet 2>/dev/null | head -20) - if [[ -n "$outdated" ]]; then - echo "[brew] Outdated packages (run: brew upgrade):" - echo "$outdated" | sed 's/^/ /' - issues=$((issues + 1)) - else - echo "[brew] All packages up to date" - fi - fi - - # mise outdated toolchains - if command -v mise &>/dev/null; then - local mise_outdated - mise_outdated=$(mise outdated 2>/dev/null | grep -v "^Tool" | grep -v "^$" | head -10) - if [[ -n "$mise_outdated" ]]; then - echo "[mise] Outdated toolchains (run: mise upgrade):" - echo "$mise_outdated" | sed 's/^/ /' - issues=$((issues + 1)) - else - echo "[mise] All toolchains up to date" - fi - fi - - # npm audit (only if a package.json is in the current directory) - if command -v npm &>/dev/null && [[ -f "$(pwd)/package.json" ]]; then - echo "[npm] Running audit in $(pwd)..." - npm audit --audit-level=high 2>/dev/null || true - fi - - if [[ $issues -eq 0 ]]; then - echo "All checks passed." - else - echo "" - echo "Tip: keep tools at stable LTS, not bleeding edge — run update checks weekly." - fi -} -# <<< devboost security end -EOF -) - - if [[ "${DB_DRY_RUN:-false}" == "true" ]]; then - db_log_info "Would inject security alias block into: $include_file" - return 0 - fi - - db_upsert_block "$include_file" \ - "# >>> devboost security start" \ - "# <<< devboost security end" \ - "$block" - - db_log_success "Security: devboost-check alias available in new shells" -} - -db_module_security_doctor() { - local enable=$(db_yaml_get '.security.enable' 'true') - [[ "$enable" != "true" ]] && return 0 - - local found_issues=0 - - # Warn if any mise toolchain is pinned to 'latest' — these pull unverified new releases - # immediately. 'lts' and 'stable' are safer because they track a vetted release stream. - for key in node python go rust deno; do - local ver=$(db_yaml_get ".toolchains.globals.${key}" '') - if [[ "$ver" == "latest" ]]; then - db_log_warn "security: toolchain '$key' is pinned to 'latest' — prefer a specific version or 'lts'/'stable' to avoid pulling unreviewed releases" - found_issues=$((found_issues + 1)) - fi - done - - # Warn if oh-my-zsh is installed alongside devboost — it's redundant since devboost - # already provides a plugin manager (znap), prompt (starship), and curated plugins. - # Running both can cause slow shell startup and conflicting keybindings/completions. - if [[ -d "$HOME/.oh-my-zsh" ]]; then - db_log_warn "security: ~/.oh-my-zsh detected — this is redundant with devboost's znap+starship setup and can slow shell startup or conflict with it. Consider removing it (see README)." - found_issues=$((found_issues + 1)) - fi - - # Warn if .zshrc sources .zshrc.devboost twice (devboost's own marked block, - # plus a leftover unmarked line from a prior manual edit or recovery). Doubles - # the cost of everything in .zshrc.devboost — znap, atuin, mise, aliases, etc. - # — on every shell start. - local zshrc="$HOME/.zshrc" - if [[ -f "$zshrc" ]]; then - local devboost_source_count - devboost_source_count=$(grep -c '\.zshrc\.devboost' "$zshrc" 2>/dev/null || echo 0) - if [[ "$devboost_source_count" -gt 1 ]]; then - db_log_warn "security: $zshrc sources .zshrc.devboost $devboost_source_count times — likely double-sourced, which doubles shell startup cost. Run: grep -n zshrc.devboost $zshrc" - found_issues=$((found_issues + 1)) - fi - fi - - # Check if TPM was cloned over HTTPS (not HTTP) - local tpm_path=$(db_yaml_get '.tmux.tpm_path' "$HOME/.tmux/plugins/tpm") - if [[ -d "$tpm_path/.git" ]]; then - local tpm_remote - tpm_remote=$(git -C "$tpm_path" remote get-url origin 2>/dev/null || echo '') - if [[ "$tpm_remote" == http://* ]]; then - db_log_warn "security: TPM remote uses plain HTTP — re-clone over HTTPS" - found_issues=$((found_issues + 1)) - fi - fi - - # Check Homebrew/apt for significantly outdated packages (more than ~6 months behind) - # We don't have reliable version-age data here, so we check if the package manager - # itself hasn't been updated in a long time by inspecting its metadata freshness. - if command -v brew &>/dev/null; then - # If the Homebrew index is >7 days old without an update, warn - local brew_repo - brew_repo="$(brew --repository 2>/dev/null)/Library/Taps/homebrew/homebrew-core" - if [[ -d "$brew_repo/.git" ]]; then - local last_fetch - last_fetch=$(git -C "$brew_repo" log -1 --format="%ct" 2>/dev/null || echo 0) - local now - now=$(date +%s) - local age_days=$(( (now - last_fetch) / 86400 )) - if [[ $age_days -gt 7 ]]; then - db_log_warn "security: Homebrew index is ${age_days} days old — run 'brew update' to get security patches" - found_issues=$((found_issues + 1)) - fi - fi - fi - - if [[ $found_issues -eq 0 ]]; then - db_log_success "security: No obvious issues found" - else - db_log_warn "security: Run 'devboost-check' (after applying) for a live update summary" - fi -} - -# === Module Registration === -db_load_modules() { - # Register all modules - db_module_pkg_register - db_module_znap_register - db_module_zsh_register - db_module_starship_register - db_module_tmux_register - db_module_mise_register - db_module_corepack_register - db_module_direnv_register - db_module_git_register - db_module_services_register - db_module_security_register - - db_log_verbose "Loaded ${#DB_MODULE_NAMES[@]} modules" -} - -# === Main Execution === -# Run main if script is executed directly -# Use ${BASH_SOURCE[0]:-} to handle unbound variable (when piped from stdin) -# When piped: BASH_SOURCE[0] is unbound/empty, $0 is usually "-bash" or starts with "-" -# When executed directly: BASH_SOURCE[0] == $0 -# When sourced: BASH_SOURCE[0] != $0 (and we don't want to run) -_bash_source="${BASH_SOURCE[0]:-}" -if [[ "$_bash_source" == "${0}" ]] || [[ -z "$_bash_source" ]]; then - coreMain "$@" -fi diff --git a/devboost.sh.in b/devboost.sh.in deleted file mode 100644 index a551165..0000000 --- a/devboost.sh.in +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -# devboost - Bootstrap a modern dev environment -# Idempotent, config-driven, non-destructive -# This file is the entry point - core and modules are concatenated during build - -set -euo pipefail - -# Version is set in core_main.sh - diff --git a/docs/tool-choice-review-2026-08.md b/docs/tool-choice-review-2026-08.md new file mode 100644 index 0000000..9d3c130 --- /dev/null +++ b/docs/tool-choice-review-2026-08.md @@ -0,0 +1,166 @@ +# Tool-Choice Review — 2026-08-08 + +The first pass of the periodic adversarial re-review process (see +[issue #9](https://github.com/rolfsormo/devboost/issues/9)): seven +independent research passes, each asked two questions per category — +what would we pick with no history, and does the current default still +hold up. This document is the record of that pass; a formatted version +is also published as an artifact (link in the original PR/commit, if +still live — artifacts aren't guaranteed permanent, this file is the +durable copy). + +19 defaults reviewed. 0 code changes made by the review itself — see +"Status" below for what was actually implemented afterward. + +## Summary + +| Category | Current (at time of review) | Verdict | Why | +|---|---|---|---| +| zsh plugin manager | znap | **Reconsider** | Benchmarks favor antidote/sheldon; zinit (issue #6) is actually slower without careful tuning | +| Syntax highlighting | zsh-syntax-highlighting | **Reconsider** | fast-syntax-highlighting is the widely-adopted faster successor | +| Shell prompt | starship | Keep | Lead widened — 2.5× stars, 3.7× installs vs. oh-my-posh | +| └ `command_timeout` | 700ms | Uncertain (doc-only) | Deliberate deviation from upstream's 500ms — reasonable, was mislabeled | +| └ `truncation_length` | 3 | Keep | Matches starship's own default exactly | +| └ `add_newline` | false | Uncertain (doc-only) | Overrides upstream default — no consensus either way, genuine taste call | +| Terminal multiplexer | tmux | Keep | Zellij real but hasn't overtaken; tmux fits unattended-bootstrap better | +| tmux plugin manager | TPM | Keep | Stagnant but not dead; successor (tpack) too new to trust | +| └ tmux-logging plugin | bundled always-on | **Reconsider** | 3–4× smaller adoption than the other three; should be opt-in | +| Toolchain manager | mise | Keep | Lead over asdf widened, not narrowed | +| └ python pin | 3.14 | Keep | Current stable line | +| └ go pin | 1.26 | **Stale value** | 1.27 release notes already published — GA imminent | +| └ node | lts (rolling) | Keep | Correct design — self-updates with zero devboost changes | +| cd / fuzzy / grep / find / cat / ls / du / git TUI / pager | zoxide, fzf, ripgrep, fd, bat, eza, dust, lazygit, delta | Keep | All confirmed clear favorites, no credible challenger found | +| df replacement | duf | Watch | Last release Sep 2025; nothing better exists yet | +| ps replacement | procs | Uncertain (doc-only) | Right pick for its niche, smaller community — was overstated in the doc comment | +| Shell history | atuin | Keep | Clear leader by adoption, activity, shell-support breadth | +| └ `filter_mode` | directory | **Reconsider** | Diverges from atuin's own default; most real configs scope only the up-arrow key | +| Node package-manager shim | corepack via npm | Keep | Matches Node TSC's own recommended path, no successor planned | + +## What changed as a result + +See git history following this file's commit for the actual +implementation of each **Reconsider**/**Stale** item and each doc-only +relabel. In short: + +1. **zsh plugin manager (znap → reconsider)**: not migrated in this + pass — a manager swap is a bigger, riskier change than the other + items here and deserves its own dedicated PR, not a bundled + drive-by. Issue #6 (which proposed znap → zinit) was updated with + the benchmark evidence below, since its original premise doesn't + hold up. +2. **Syntax highlighting plugin**: swapped `zsh-users/zsh-syntax-highlighting` + → `zdharma-continuum/fast-syntax-highlighting`. +3. **tmux-logging**: gated behind `tmux.plugins.logging.enable`, + default `false`. +4. **atuin `filter_mode`**: reverted to atuin's own default (`global`), + with `filter_mode_shell_up_key_binding: directory` added for the + quick-recall behavior the original setting likely intended. +5. **mise go pin**: left at 1.26 (still resolves to a current, patched + release) with a doc-comment note to bump once 1.27 is GA — not + urgent enough to guess a version number that isn't released yet. +6. **starship `command_timeout`/`add_newline`, procs**: doc comments + relabeled to state these plainly as a deliberate deviation and a + genuine taste call, rather than implying settled consensus. + +## Full evidence, category by category + +### zsh plugin manager (znap → reconsider, not yet migrated) + +Two reproducible benchmark suites decide this. +[rossmacarthur/zsh-plugin-manager-benchmark](https://github.com/rossmacarthur/zsh-plugin-manager-benchmark) +(Docker + hyperfine, 23 real plugins, no turbo-mode tricks) puts +**antidote and sheldon** in the top, statistically-indistinguishable +tier — and puts **zinit in the "notably bad load time" tier**, +alongside zplug. [zsh-bench](https://github.com/romkatv/zsh-bench) +shows zinit can match antidote, but only with every plugin hand-annotated +with `wait`/`lucid` ice-modifiers — not the easy path. znap itself +appears in neither suite: there is no third-party-verified speed number +for it at all, only its own README claims. + +Issue #6 proposed znap → zinit based on a conflict/dedup investigation, +not a controlled speed test — its premise ("the user's hand-tuned setup +uses zinit, so zinit must be fast") is contradicted by the benchmark +data. Migrating as originally scoped in that issue risks making startup +*worse* unless devboost also generates per-plugin turbo config. + +**Recommendation**: don't migrate to zinit per issue #6 as originally +scoped. Evaluate antidote first (static-bundle architecture, hard to +misconfigure into slowness — matters for unattended config generation), +sheldon as an equally defensible second choice (Rust binary, TOML +config, easy to template from Go). + +### Syntax highlighting plugin (reconsider → implemented) + +`zsh-users/zsh-syntax-highlighting` is widely superseded by +`zdharma-continuum/fast-syntax-highlighting` for performance. Notably, +the user's own pre-existing zinit setup found in the original dedup +investigation was already using fast-syntax-highlighting — evidence +from inside this repo's own history that devboost's pick lagged a real +hand-tuned setup. + +### tmux-logging plugin (reconsider → implemented) + +resurrect (12,990★) + continuum (4,046★) are a deliberate, +still-recommended pair; yank (3,095★) solves an unmatched SSH-clipboard +problem. tmux-logging (1,251★) is a 3–4× adoption drop from yank and is +absent from most "essential setup" roundups that otherwise mirror the +other three exactly. + +### atuin `filter_mode` (reconsider → implemented) + +Atuin's own docs state the default is `global`, not `directory` — +devboost's override wasn't justified anywhere in the module. Real-world +configs that want directory-scoped recall typically keep ctrl-r search +global and scope *only* the up-arrow key via the separate +`filter_mode_shell_up_key_binding` setting. devboost's previous flat +setting restricted both, which is more limiting for interactive search +than most documented setups. + +### mise go pin — 1.26 (stale value, watching) + +Go 1.27 release notes are already published at go.dev — GA is +imminent. 1.26.5 (current patch) carries two CVE fixes, which the +`1.26` channel already resolves to automatically, so nothing is broken +today. This is exactly the drift pattern the module's own rationale +comment predicted. + +### duf (df replacement) — watch, no action + +Last release Sep 2025; recent commits are dependency bumps only. Still +the largest tool in its niche — the df-replacement space has very few +entrants at all, and no better alternative was found. + +### Confirmed, no action + +- **starship** — adoption lead widened (2.5× stars, 3.7× Homebrew + installs vs. oh-my-posh); powerlevel10k confirmed unmaintained by its + own maintainer. +- **tmux + TPM** — Zellij hasn't overtaken tmux; TPM's likely successor + (tpack) is 6 months old at 201★, too early to trust. +- **mise** — lead over asdf widened; now 10th most-downloaded Homebrew + formula overall. +- **zoxide, fzf, ripgrep, fd, bat, eza, lazygit, delta, atuin** — all + confirmed clear community favorites by live star/activity data, no + credible challenger found for any. +- **dust** — healthy and leading; gdu noted as a legitimate alternative + for very large filesystem scans, not a replacement. +- **corepack** (`npm install -g corepack`) — matches Node's own + TSC-recommended path; no successor exists or is planned. + +### Uncertainty carried forward, not resolved + +- **antidote vs. sheldon** — called "a very close, equally-defensible + second choice" between the two; the original research didn't claim a + single winner, only that both beat znap and zinit. Left open for + whoever picks up the zsh-plugin-manager migration. +- **tpack** (possible TPM successor) — explicitly "not enough field use + to trust yet." Revisit in a year, neither adopt nor dismiss now. + +--- + +*Synthesized from 7 parallel research passes — zsh plugin manager, +prompt, tmux stack, toolchain manager, CLI tool bundle, git-delta & +shell history, corepack/Node currency. See individual module doc +comments in `engine/modules/` for how each finding was folded into the +code, and `.agents/skills/devboost-module-author/SKILL.md` for the +process this review followed.* diff --git a/engine/apply.go b/engine/apply.go new file mode 100644 index 0000000..a1c77a7 --- /dev/null +++ b/engine/apply.go @@ -0,0 +1,43 @@ +package engine + +import "fmt" + +// Apply diffs and executes resources one at a time, in dependency order — +// see the package doc for why this can't be "compute the same diff Plan +// computes, then execute it": a resource that depends on another must be +// diffed after that dependency has actually executed, not against a +// stale, pre-execution view of the system. +// +// A failed resource doesn't stop the rest of the run — see +// DiffAndExecute's ExecutionResult. Apply reports every outcome (done, +// failed, skipped-because-a-dependency-failed) and returns a non-nil +// error only when at least one resource actually failed, so callers +// (e.g. the CLI) still exit non-zero, but everything that could +// converge, did. +func Apply(resources []Resource) error { + result, err := DiffAndExecute(resources, func(op PendingOp) { + fmt.Printf("%s...\n", op.Description) + }) + if err != nil { + return err + } + + for _, op := range result.Applied { + fmt.Printf("Done: %s\n", op.Description) + } + if len(result.Applied) == 0 && len(result.Failed) == 0 && len(result.Skipped) == 0 { + fmt.Println("No changes.") + } + + if len(result.Failed) == 0 { + return nil + } + + for id, ferr := range result.Failed { + fmt.Printf("Failed: %s: %v\n", id, ferr) + } + for id, blocker := range result.Skipped { + fmt.Printf("Skipped: %s (depends on failed resource %s)\n", id, blocker) + } + return fmt.Errorf("%d resource(s) failed to apply", len(result.Failed)) +} diff --git a/engine/apply_test.go b/engine/apply_test.go new file mode 100644 index 0000000..9a5ccb7 --- /dev/null +++ b/engine/apply_test.go @@ -0,0 +1,37 @@ +package engine + +import ( + "errors" + "testing" +) + +func TestApplyReturnsErrorWhenAResourceFails(t *testing.T) { + var ran []string + resources := []Resource{ + {ID: "ok", Kind: fakeKind{pending: true, desc: "ok", ran: &ran, id: "ok"}}, + {ID: "bad", Kind: fakeKind{pending: true, desc: "bad", ran: &ran, id: "bad", failErr: errors.New("boom")}}, + } + + err := Apply(resources) + if err == nil { + t.Fatal("expected an error when a resource fails") + } + if len(ran) != 1 || ran[0] != "ok" { + t.Fatalf("expected the unrelated resource to still have executed, ran = %v", ran) + } +} + +func TestApplyNoErrorWhenEverythingSucceeds(t *testing.T) { + var ran []string + resources := []Resource{ + {ID: "a", Kind: fakeKind{pending: true, desc: "a", ran: &ran, id: "a"}}, + {ID: "b", Kind: fakeKind{pending: true, desc: "b", ran: &ran, id: "b"}}, + } + + if err := Apply(resources); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ran) != 2 { + t.Fatalf("expected both resources to execute, ran = %v", ran) + } +} diff --git a/engine/diagnostic.go b/engine/diagnostic.go new file mode 100644 index 0000000..70f2fa3 --- /dev/null +++ b/engine/diagnostic.go @@ -0,0 +1,28 @@ +package engine + +// Diagnostic is a read-only finding with nothing to converge — e.g. "this +// toolchain is pinned to 'latest', which pulls unreviewed releases," or +// "oh-my-zsh is installed alongside devboost, which is redundant." This +// is deliberately a separate concept from Resource/PendingOp, not another +// use of PendingOp with a no-op Execute: a Diagnostic was never a pending +// *change* in the first place, so it shouldn't appear in apply's "N +// changes made" accounting the way a real converged resource does, and a +// module offering only diagnostics (nothing to install/fix) is a +// different, legitimate shape from a module with nothing to report at +// all — Diagnostics lets that distinction be real instead of implied by +// an empty PendingOp. +// +// A DiagnosticFunc returning nil means nothing to report. +type Diagnostic struct { + Module string // which module this finding belongs to, for grouped doctor output + Message string + Warn bool // true for a warning-level finding, false for informational/success +} + +// DiagnosticFunc is what a module supplies for doctor: a function that +// inspects live system state and returns zero or more findings. Unlike +// ResourceKind.Diff, there's no "desired vs. actual" comparison implied — +// a diagnostic can report on anything worth surfacing, including things +// with no notion of convergence at all (e.g. "an unrelated tool your +// devboost setup didn't install is present"). +type DiagnosticFunc func() ([]Diagnostic, error) diff --git a/engine/doctor.go b/engine/doctor.go new file mode 100644 index 0000000..dac55ef --- /dev/null +++ b/engine/doctor.go @@ -0,0 +1,67 @@ +package engine + +import "fmt" + +// ModuleReport is one module's worth of doctor output: its pending +// resource changes (same shape plan/apply already compute, just grouped +// by module) plus any read-only Diagnostics. +type ModuleReport struct { + Name string + Pending []PendingOp + Diagnostics []Diagnostic +} + +// Doctor computes ONE combined diff across every module's resources +// together — same as Plan — then groups the resulting PendingOps back by +// which module owns each resource ID for readable, tool-first output. +// +// This must diff the combined graph, not each module in isolation: a +// resource can legitimately DependsOn another module's resource (e.g. +// security's alias-block injection depends on zsh having already +// written .zshrc.devboost, since both target the same file with +// incompatible write semantics — diffing security's resources alone +// would make that dependency unresolvable, since the resource it depends +// on wouldn't even be in the list). Grouping happens after the diff, as +// a pure reporting step, not by fragmenting the diff itself. +func Doctor(modules []string, resourcesByModule [][]Resource, diagnosticsByModule []DiagnosticFunc) ([]ModuleReport, error) { + if len(modules) != len(resourcesByModule) || len(modules) != len(diagnosticsByModule) { + return nil, fmt.Errorf("doctor: modules/resources/diagnostics length mismatch") + } + + moduleOf := make(map[string]string) // resource ID -> owning module name + var combined []Resource + for i, name := range modules { + for _, r := range resourcesByModule[i] { + moduleOf[r.ID] = name + combined = append(combined, r) + } + } + + pending, err := ComputeDiff(combined) + if err != nil { + return nil, err + } + + pendingByModule := make(map[string][]PendingOp) + for _, op := range pending { + name := moduleOf[op.ResourceID] + pendingByModule[name] = append(pendingByModule[name], op) + } + + reports := make([]ModuleReport, 0, len(modules)) + for i, name := range modules { + var diags []Diagnostic + if fn := diagnosticsByModule[i]; fn != nil { + diags, err = fn() + if err != nil { + return nil, fmt.Errorf("module %s diagnostics: %w", name, err) + } + } + modulePending := pendingByModule[name] + if len(modulePending) == 0 && len(diags) == 0 { + continue // nothing to report for this module at all + } + reports = append(reports, ModuleReport{Name: name, Pending: modulePending, Diagnostics: diags}) + } + return reports, nil +} diff --git a/engine/doctor_test.go b/engine/doctor_test.go new file mode 100644 index 0000000..bc6ea49 --- /dev/null +++ b/engine/doctor_test.go @@ -0,0 +1,94 @@ +package engine + +import "testing" + +func TestDoctorGroupsByModuleAndOmitsClean(t *testing.T) { + pendingKind := fakeKind{pending: true, desc: "fix me", ran: &[]string{}, id: "a"} + cleanKind := fakeKind{pending: false} + + modules := []string{"dirty_module", "clean_module"} + resources := [][]Resource{ + {{ID: "a", Kind: pendingKind}}, + {{ID: "b", Kind: cleanKind}}, + } + diagnostics := []DiagnosticFunc{nil, nil} + + reports, err := Doctor(modules, resources, diagnostics) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 1 { + t.Fatalf("expected only the dirty module to be reported, got %d reports: %+v", len(reports), reports) + } + if reports[0].Name != "dirty_module" { + t.Fatalf("got %q, want dirty_module", reports[0].Name) + } + if len(reports[0].Pending) != 1 { + t.Fatalf("expected one pending op, got %v", reports[0].Pending) + } +} + +func TestDoctorIncludesDiagnosticsOnlyModules(t *testing.T) { + cleanKind := fakeKind{pending: false} + modules := []string{"diag_only"} + resources := [][]Resource{{{ID: "a", Kind: cleanKind}}} + diagnostics := []DiagnosticFunc{ + func() ([]Diagnostic, error) { + return []Diagnostic{{Module: "diag_only", Message: "something worth knowing"}}, nil + }, + } + + reports, err := Doctor(modules, resources, diagnostics) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 1 { + t.Fatalf("expected the diagnostics-only module to be reported, got %d", len(reports)) + } + if len(reports[0].Pending) != 0 { + t.Fatalf("expected no pending ops, got %v", reports[0].Pending) + } + if len(reports[0].Diagnostics) != 1 { + t.Fatalf("expected one diagnostic, got %v", reports[0].Diagnostics) + } +} + +func TestDoctorErrorsOnMismatchedLengths(t *testing.T) { + _, err := Doctor([]string{"a", "b"}, [][]Resource{{}}, []DiagnosticFunc{nil}) + if err == nil { + t.Fatal("expected an error for mismatched slice lengths") + } +} + +// TestDoctorResolvesCrossModuleDependencies is a regression test for a +// real bug: an earlier version of Doctor diffed each module's resources +// in isolation, one at a time. A resource legitimately depending on +// ANOTHER module's resource (like security's alias-block injection +// depending on zsh having already written .zshrc.devboost — both target +// the same file with incompatible write semantics) then failed with +// "depends on unknown resource", because the dependency target wasn't in +// that module's own resource list. Doctor must diff the combined graph +// across all modules together, then group results by module afterward — +// exactly what Plan already does, just with an extra grouping step. +// (Doctor, like Plan, never executes anything — so this only asserts +// dependency *resolution* succeeds, not that execution effects +// propagate, which DiffAndExecute's own test already covers.) +func TestDoctorResolvesCrossModuleDependencies(t *testing.T) { + moduleA := fakeKind{pending: true, desc: "converge a"} + moduleBDependsOnA := fakeKind{pending: false} + + modules := []string{"module_a", "module_b"} + resources := [][]Resource{ + {{ID: "a", Kind: moduleA}}, + {{ID: "b", Kind: moduleBDependsOnA, DependsOn: []string{"a"}}}, + } + diagnostics := []DiagnosticFunc{nil, nil} + + reports, err := Doctor(modules, resources, diagnostics) + if err != nil { + t.Fatalf("expected the cross-module dependency to resolve, got error: %v", err) + } + if len(reports) != 1 || reports[0].Name != "module_a" { + t.Fatalf("expected only module_a to report a pending change, got %+v", reports) + } +} diff --git a/engine/kinds/backup.go b/engine/kinds/backup.go new file mode 100644 index 0000000..50bf355 --- /dev/null +++ b/engine/kinds/backup.go @@ -0,0 +1,89 @@ +package kinds + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// DefaultBackupDir returns ~/.devboost/backups, matching the bash tool's +// default (DB_BACKUP_DIR). No env-var override yet — the bash version's +// --config-adjacent DB_BACKUP_DIR override isn't wired through here since +// nothing in the Go CLI surface sets it yet; add one if/when that's needed. +// Exported so callers outside this package (migrate-from-oh-my-zsh) can +// archive things into the same backup root without duplicating the path. +func DefaultBackupDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".devboost", "backups"), nil +} + +// BackupFile copies path into a fresh timestamped subdirectory of the +// backup dir before it's about to be overwritten, mirroring the bash +// tool's db_backup_file. A no-op if path doesn't exist yet (nothing to +// back up). Exported so module-local resource kinds outside this package +// (e.g. zsh's include-block handling, which has its own custom diff logic +// not shaped like any generic kind) can reuse the same backup behavior +// instead of duplicating it. +func BackupFile(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + dir, err := DefaultBackupDir() + if err != nil { + return err + } + dest := filepath.Join(dir, time.Now().Format("20060102_150405")) + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + + src, err := os.Open(path) + if err != nil { + return err + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return err + } + + out, err := os.OpenFile(filepath.Join(dest, filepath.Base(path)), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, src); err != nil { + return fmt.Errorf("backup %s: %w", path, err) + } + return nil +} + +// ArchiveDir moves dir aside into the backup root instead of deleting +// it, named -