diff --git a/.gitignore b/.gitignore index a267ef0..46cb059 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Taskfile sources cache +/.task/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..503d169 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. + +## Overview + +**dots** is a Go CLI dotfile package manager with taps, profiles, overlays, and cross-platform support. It treats Git repos as package sources ("taps"), uses path aliases for portability, and supports symlink/copy/hardlink link strategies with platform-aware resolution. + +Historical design record: [dots-design.md](dots-design.md) + +## Docs First + +- `docs/` is the source of truth for dots behavior. Code follows the docs. +- A behavior change starts in `docs/*.md`: update the doc, then change the code to match, then write tests that assert the documented behavior. +- When code and docs disagree, the code is wrong. Change a doc to match code only as a deliberate spec change, and say so in the commit. +- Documented behavior that is not implemented yet goes in the README status table as Partial/Planned — never delete it from the docs to match missing code. Update the row when the gap closes. +- `dots-design.md` is the original design record; where it conflicts with `docs/`, `docs/` wins. +- Full loop and drift checklist: [docs/development/workflow.md](docs/development/workflow.md) + +## Workflow + +```bash +go build ./cmd/dots # build +go test ./... # all tests +go test ./pkg/dots/... -v # single package, verbose +go test ./pkg/dots -run TestAliasResolver # single test by name +go test -race ./pkg/dots/... # with race detector +go vet ./... # lint +``` + +- `task install` installs the CLI. `task podman:test` and `task podman:test:profiles` run the container checks; `task shell`, `task shell:fixture`, and `task shell:profiles` drop into throwaway machines for manual testing. +- Request flow: `pkg/cli` (Cobra) → `pkg/dotsctl.Dots` → `pkg/dots.Repository`. +- New service operation: add `pkg/dotsctl/dots_.go` with an `Options` struct and a method on `*Dots`. New CLI command: add `pkg/cli/cmd_.go`. +- Commits: conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`), summary ≤72 chars, no co-author lines. + +## Critical Rules + +- All I/O goes through `*toolkit.Runtime` (cli-toolkit) so tests can sandbox it. `HTTPFetcher` additionally takes an `*http.Client` because cli-toolkit has no HTTP seam yet. +- New or changed fields on `Config`, `Manifest`, or `Profile` go doc → schema → struct: `docs/config-yaml.md` / `docs/dotfile-yaml.md` / `docs/profiles.md`, then `schemas/*.json`, then the Go struct. +- Path aliases always use forward slashes in manifests; Windows path tests also use forward slashes. +- Check errors with `errors.Is()` for sentinels and `errors.As()` for typed errors — never compare strings. +- Service tests use `dots.NewMemoryRepo()`; use FsRepo + `sandbox.NewSandbox` only when testing filesystem behavior. Assert with testify `require`. +- `FsRepo` is a skeleton — filesystem operations are not yet implemented. +- A missing config file is not an error; config falls back to `dots.DefaultConfig()`. + +## Where to Look + +| Topic | Doc | +| ----------------------------------- | -------------------------------------------------------------------- | +| Docs-first workflow, drift handling | [docs/development/workflow.md](docs/development/workflow.md) | +| Package map, key types, data flow | [docs/development/architecture.md](docs/development/architecture.md) | +| Test harness, fixtures, smoke tests | [docs/development/testing.md](docs/development/testing.md) | +| Sentinel and typed errors | [docs/development/errors.md](docs/development/errors.md) | +| JSON Schemas and modelines | [docs/development/schemas.md](docs/development/schemas.md) | +| `Dotfile.yaml` manifest format | [docs/dotfile-yaml.md](docs/dotfile-yaml.md) | +| `config.yaml` user configuration | [docs/config-yaml.md](docs/config-yaml.md) | +| Path aliases | [docs/path-aliases.md](docs/path-aliases.md) | +| Platform detection and cascade | [docs/platform-system.md](docs/platform-system.md) | +| Link strategies | [docs/link-strategies.md](docs/link-strategies.md) | +| Profiles | [docs/profiles.md](docs/profiles.md) | +| Overlays | [docs/overlays.md](docs/overlays.md) | +| Work mode | [docs/work-mode.md](docs/work-mode.md) | +| CLI commands and flags | [docs/cli-reference.md](docs/cli-reference.md) | +| Bootstrapping a machine | [docs/bootstrapping.md](docs/bootstrapping.md) | diff --git a/CLAUDE.md b/CLAUDE.md index a854460..f6aa6c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,134 +1,3 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Overview - -**dots** is a Go CLI dotfile package manager with taps, profiles, overlays, and cross-platform support. It treats Git repos as package sources ("taps"), uses path aliases for portability, and supports symlink/copy/hardlink link strategies with platform-aware resolution. - -Design specification: `dots-design.md` - -## Build & Development Commands - -```bash -# Build -go build ./cmd/dots - -# Test -go test ./... # all tests -go test ./pkg/dots/... -v # single package, verbose -go test ./pkg/dots -run TestAliasResolver # single test by name -go test -race ./pkg/dots/... # with race detector - -# Lint -go vet ./... -``` - -## Architecture - -### Package Map - -- **`cmd/dots/`** — Binary entrypoint: `toolkit.NewRuntime()` → `cli.Run()` -- **`pkg/cli/`** — Cobra command definitions bridging CLI flags to `pkg/dotsctl`. Each command in its own `cmd_*.go` file. -- **`pkg/dots/`** — Core library: platform detection, path aliases, cascade merge, config/manifest parsing, repository interface, error types, and the artifact subsystem (`Artifact`, `Fetcher`/`HTTPFetcher`/`MemoryFetcher`, `Extractor` with zip-slip / tar-slip hardening). The fetcher and extractor route all FS I/O through `*toolkit.Runtime`; `HTTPFetcher` additionally takes an `*http.Client` because cli-toolkit does not yet expose an HTTP seam. -- **`pkg/dotsctl/`** — Service layer: `Dots` struct composing `PathService`, `ConfigService`, and `Repository`. Each operation in its own `dots_*.go` file (file-per-operation pattern). - -### Key Types and Flow - -``` -CLI command → pkg/cli (Cobra) → pkg/dotsctl.Dots → pkg/dots.Repository -``` - -**Dots** (`pkg/dotsctl/dots.go`) is the root service struct composing: - -- `PathService` — platform-native directory resolution -- `ConfigService` — cached config loading with defaults fallback -- `Repository` — storage backend (MemoryRepo for tests, FsRepo for production) - -**Repository** (`pkg/dots/repository.go`) is the storage contract with: - -- `MemoryRepo` (`repo_memory.go`) — in-memory, thread-safe, used in tests -- `FsRepo` (`repo_fs.go`) — filesystem-backed (skeleton, not yet implemented) - -**Platform** (`pkg/dots/platform.go`) detects OS-arch at runtime and resolves path aliases (`@config`, `@data`, `@home`, `@cache`, `@state`, `@bin`) to platform-native paths. - -**AliasResolver** (`pkg/dots/platform.go`) expands alias paths with XDG support on Unix and `%APPDATA%`/`%LOCALAPPDATA%` on Windows. Supports custom user aliases with chaining. - -**DeepMerge / ResolvePlatformCascade** (`pkg/dots/platform_cascade.go`) merges config sections in specificity order: base → OS → OS-arch. Maps deep-merge, scalars replace, lists concatenate-dedup. - -### File-Per-Operation Pattern - -Service operations follow a consistent pattern in `pkg/dotsctl/`: - -``` -dots.go # Dots struct, NewDots(), DotsOptions -dots_init.go # InitOptions + (*Dots).Init() -dots_list.go # ListOptions + (*Dots).List() -dots_status.go # StatusResult + (*Dots).Status() -dots_doctor.go # DoctorCheck + (*Dots).Doctor() -``` - -Each file contains an `Options` struct and a method on `*Dots`. New operations are added by creating a new `dots_.go` file. - -### Config Hierarchy - -- User config: `~/.config/dots/config.yaml` (Unix) or `%APPDATA%\dots\config.yaml` (Windows) -- Defaults: `dots.DefaultConfig()` provides sensible fallbacks -- Platform overrides: `config.yaml` `platform:` section applies per-OS/arch overrides - -Config is loaded by `ConfigService` in `pkg/dotsctl/config_service.go`. - -### Manifest Resolution - -`Dotfile.yaml` manifests support platform cascade resolution: - -```yaml -links: - init.lua: "@config/nvim/init.lua" # base -platform: - darwin: - links: - clipboard.lua: "@config/nvim/lua/clipboard.lua" # OS override - darwin-arm64: - links: - bin/nvim-arm: "@bin/nvim-silicon" # OS-arch override -``` - -`dots.ResolveManifest(manifest, platform)` produces the effective manifest for the current platform. - -### Dependency: cli-toolkit - -`github.com/jlrickert/cli-toolkit` provides `toolkit.Runtime` — the explicit dependency container carrying filesystem, env, clock, logger, hasher, stream, and process identity. All I/O flows through Runtime, enabling sandboxed test environments. - -## Testing - -- **Sandbox pattern**: CLI tests use `sandbox.NewSandbox(t, ...)` from cli-toolkit with a jailed temp directory and test runtime. -- **Fixtures**: `pkg/cli/data/` contains embedded test fixtures (config, tap manifests). -- **RunCommand harness**: `RunCommand(t, sb, args...)` in `testhelpers_test.go` runs dots CLI commands and returns stdout/stderr/exitCode. -- **MemoryRepo for speed**: Service layer tests use `dots.NewMemoryRepo()`. Use FsRepo + sandbox only when testing filesystem behavior. -- **Testify**: Uses `github.com/stretchr/testify/require` for assertions. - -## Error Handling - -- Sentinel errors in `pkg/dots/errors.go`: `ErrNotExist`, `ErrExist`, `ErrParse`, `ErrConflict`, `ErrChecksumMismatch`, `ErrUnsupportedExtract`. -- Typed errors: `TapNotFoundError`, `PackageNotFoundError`, `InvalidConfigError`. -- Check with `errors.Is()` for sentinels, `errors.As()` for typed errors. -- `TapNotFoundError` and `PackageNotFoundError` both satisfy `errors.Is(err, ErrNotExist)`. - -## JSON Schemas - -- `schemas/dotfile.json` — Draft 7 schema for `Dotfile.yaml` package manifests. -- `schemas/dots-config.json` — Draft 7 schema for `config.yaml` user configuration. -- `schemas/profile.json` — Draft 7 schema for profile definitions in `~/.config/dots/profiles/.yaml`. -- Schema URL constants and modeline strings live in `pkg/dots/config.go`, `pkg/dots/manifest.go`, and `pkg/dots/profile.go`. -- `ConfigService.Save` and `dots init` prepend the `yaml-language-server` modeline to written config files. `MarshalProfile` does the same for profiles, so all profile writers (`ProfileCreate`, `ProfileExport`, internal `saveProfile`) inherit it. -- `DotfileSchemaModeline` is defined but not auto-injected — dots does not currently scaffold `Dotfile.yaml` files. Use it when adding such a command. -- When adding new fields to `Config`, `Manifest`, or `Profile` structs, update the corresponding JSON Schema file to keep them in sync. - -## Gotchas - -- Path aliases always use forward slashes in manifests. `filepath.ToSlash`/`filepath.FromSlash` handles normalization at resolution time. -- Windows path tests use forward slashes since `filepath.Join` on non-Windows hosts won't produce backslashes. -- The `FsRepo` is a skeleton — filesystem operations are not yet implemented. -- Config falls back to `DefaultConfig()` when no config file exists (not an error). -- Commit conventions: conventional commits (`feat:`, `fix:`, `refactor:`), summaries ≤72 chars. No co-author lines. +@AGENTS.md diff --git a/Containerfile b/Containerfile index c6afa76..6a094c5 100644 --- a/Containerfile +++ b/Containerfile @@ -11,15 +11,26 @@ # emit completions via `dots completion`. # dots-from-deb — `apt install /tmp/dist/dots_*_${ARCH}.deb`. Exercises # the packaging pipeline produced by `task package:deb`. -# runtime — picks dots-from-source or dots-from-deb based on +# machine — picks dots-from-source or dots-from-deb based on # BUILD_FROM, then layers on the bash/zsh completion -# bootstrap, the tester user, the fixture, and runs -# dots init/install. Entrypoint = verify.sh. +# bootstrap and the tester user. A clean machine with +# dots, git, and ssh but nothing installed. `task shell`. +# fixture-src — machine + the synthetic fixture tap, snapshot-committed. +# runtime — fixture-src + dots init/install of the fixture packages. +# Entrypoint = verify.sh. `task test` / `task shell:fixture`. +# profiles — machine + the profiles fixture tap, bootstrapped but with +# no profile applied. Entrypoint = verify-profiles.sh. +# `task test:profiles` / `task shell:profiles`. # -# Build (default, source variant): -# podman build -t dots-test:dev . +# Build (smoke image, source variant). --target is required: the last stage in +# this file is `profiles`, so a targetless build would produce that instead. +# podman build --target runtime -t dots-test:dev . +# Build (clean machine): +# podman build --target machine -t dots-test:machine . +# Build (profiles fixture): +# podman build --target profiles -t dots-test:profiles . # Build (deb variant — requires `task package:deb` first): -# podman build --build-arg BUILD_FROM=deb -t dots-test:deb . +# podman build --target runtime --build-arg BUILD_FROM=deb -t dots-test:deb . # Run: podman run --rm dots-test:dev # Debug: podman run --rm -it --entrypoint bash dots-test:dev # @@ -57,6 +68,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ git \ locales \ + openssh-client \ python3 \ sudo \ tzdata \ @@ -95,11 +107,12 @@ RUN ARCH="$(dpkg --print-architecture)" \ && rm -rf /var/lib/apt/lists/* /tmp/dist \ && dots --version -# ---------- Stage: runtime ---------- -# Final image. BUILD_FROM picks which intermediate provides the dots binary -# and completion scripts; everything below this point is identical across -# variants. -FROM dots-from-${BUILD_FROM} AS runtime +# ---------- Stage: machine ---------- +# Clean machine: the dots binary, shell completion, git, ssh, and a `tester` +# user, with nothing installed and no tap registered. BUILD_FROM picks which +# intermediate provides the dots binary and completion scripts; everything +# below this point is identical across variants. +FROM dots-from-${BUILD_FROM} AS machine # bash: append an uncommented bash-completion bootstrap. Ubuntu 24.04 ships # /etc/bash.bashrc with the equivalent block commented out — the package @@ -135,8 +148,25 @@ RUN useradd --create-home --shell /usr/bin/zsh tester \ && echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester \ && chmod 0440 /etc/sudoers.d/tester +# Fixture worktrees below are owned by uid 1000 (tester) while some steps run +# as root, so git needs the safe.directory escape hatch image-wide. +RUN git config --system --add safe.directory '*' + +# entry-live.sh backs the interactive `task shell*` targets: it makes a +# read-only /mnt/ssh mount usable before exec'ing the requested shell. +COPY containers/entry-live.sh /usr/local/lib/dots-test/entry-live.sh +RUN chmod +x /usr/local/lib/dots-test/entry-live.sh + +ENV SHELL=/bin/bash \ + HOME=/home/tester +USER tester +WORKDIR /home/tester + +# ---------- Stage: fixture-src ---------- # Ship the synthetic fixture under /opt/dotfiles-src. Owned by tester so the # snapshot commit (next RUN, run as tester) doesn't trip git's safe.directory. +FROM machine AS fixture-src + COPY --chown=tester:tester containers/fixture /opt/dotfiles-src # `dots init --from file://` clones from the COMMITTED git tree, not the @@ -144,9 +174,6 @@ COPY --chown=tester:tester containers/fixture /opt/dotfiles-src # dots repo as plain files, so this stage initializes a one-commit repo # inside the image to satisfy that contract. This is the dev/1091 workaround # (snapshot-commit pattern). -USER tester -ENV SHELL=/bin/bash \ - HOME=/home/tester RUN cd /opt/dotfiles-src \ && git init -q -b main \ && git config user.email "build@dots-test.local" \ @@ -154,14 +181,16 @@ RUN cd /opt/dotfiles-src \ && git add -A \ && git commit -q -m "fixture snapshot" +# ---------- Stage: runtime ---------- +# The smoke image: bootstraps the fixture tap and installs every fixture +# package, then runs verify.sh. +FROM fixture-src AS runtime + # Install as root so any post_install hooks that may want to touch system # state (apt, etc.) work. None of the synthetic fixture's hooks do, but the -# pattern matches the production reference image. The safe.directory glob is -# required because the fixture worktree is owned by uid 1000 (tester) while -# we're now running as root. +# pattern matches the production reference image. USER root -RUN git config --system --add safe.directory '*' \ - && HOME=/home/tester dots init \ +RUN HOME=/home/tester dots init \ --from "file:///opt/dotfiles-src" \ --path dots-config \ --name fixture \ @@ -183,3 +212,32 @@ RUN chmod +x /usr/local/lib/dots-test/verify.sh /usr/local/lib/dots-test/lib.sh USER tester WORKDIR /home/tester ENTRYPOINT ["/usr/local/lib/dots-test/verify.sh"] + +# ---------- Stage: profiles ---------- +# Profiles fixture: a tap whose dots-config ships profiles and sets +# core.always_installed. Bootstrapped only — no profile is applied, so +# `dots profile apply` is what you exercise here. +FROM machine AS profiles + +COPY --chown=tester:tester containers/fixture-profiles /opt/dotfiles-profiles + +RUN cd /opt/dotfiles-profiles \ + && git init -q -b main \ + && git config user.email "build@dots-test.local" \ + && git config user.name "dots-test build" \ + && git add -A \ + && git commit -q -m "profiles fixture snapshot" + +USER root +RUN HOME=/home/tester dots init \ + --from "file:///opt/dotfiles-profiles" \ + --path dots-config \ + --name fixture \ + && chown -R tester:tester /home/tester + +COPY containers/lib.sh containers/verify-profiles.sh /usr/local/lib/dots-test/ +RUN chmod +x /usr/local/lib/dots-test/verify-profiles.sh /usr/local/lib/dots-test/lib.sh + +USER tester +WORKDIR /home/tester +ENTRYPOINT ["/usr/local/lib/dots-test/verify-profiles.sh"] diff --git a/README.md b/README.md index c244ed5..fba92cd 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ This creates a default config at `~/.config/dots/config.yaml` (Unix) or `%APPDAT | Feature | Status | Notes | | ---------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Core Operations** | | | -| Init (`--from`, `--path`) | Done | Self-bootstrapping from existing repo | +| Init (`--from`, `--path`, `--apply`) | Done | Self-bootstrapping from existing repo, then optional profile apply | | Install | Done | Manifest parsing, platform cascade, hooks, lockfile, `--dry-run`, `--strategy` | | Remove | Done | Lockfile lookup, link removal, backup restore, hooks | | Upgrade (`--all`) | Done | Tap update + reinstall | @@ -126,9 +126,14 @@ This creates a default config at `~/.config/dots/config.yaml` (Unix) or `%APPDAT | Diff | Done | Checksum comparison for copy-strategy files | | Which | Done | Identify which package owns a file | | **Profiles** | | | -| Full CRUD + apply / switch | Done | create, delete, list, show, add, remove, apply, switch | +| Full CRUD + apply | Done | create (`--extends`), delete, list, show (resolved, with sources), add, remove, extend, unextend, exclude, unexclude, apply | +| Reconciling `profile apply` | Done | Removes packages not in the profile, installs missing ones, `--dry-run`. Replaces `profile switch` | +| Inheritance (`extends`) | Done | Multiple parents, first-occurrence dedup, cycle detection | +| Profile `exclude` | Done | Drops inherited packages; `exclude`/`unexclude` commands; `show` flags unmatched excludes | +| Active profile in lockfile only | Done | `state.active_profile` in the lockfile; `core.active_profile` is no longer read | +| Always-installed packages | Done | `core.always_installed` in config; included in every profile apply, overrides `exclude`. See [Profiles](docs/profiles.md#always-installed-packages) | +| `dots init --apply` | Done | Applies a profile after bootstrap; config is reloaded first so `always_installed` from the bootstrap package applies | | Export / Import | Done | YAML-based profile sharing | -| Inheritance (`extends`) | Done | Chain resolution with dedup | | **Work Mode** | | | | On / Off / Status / Rebuild | Done | Automatic re-linking on toggle | | **Sync** | | | @@ -206,11 +211,11 @@ Profiles are named lists of packages that can be applied together: ```bash dots profile create work dots profile add work personal/zsh personal/nvim personal/git -dots profile apply work -dots profile switch personal # switch to a different profile +dots profile apply work # install work's packages, remove everything else +dots profile apply personal # change to a different profile ``` -Profiles support inheritance via `extends` and can be exported/imported: +Profiles can extend one or more other profiles, exclude inherited packages, and be exported/imported: ```bash dots profile export work > work-profile.yaml @@ -326,4 +331,4 @@ For the full configuration reference, see [config.yaml Reference](docs/config-ya | [Work Mode](docs/work-mode.md) | Development workflow with local checkouts | | [Bootstrapping](docs/bootstrapping.md) | Self-bootstrapping and machine recovery | -For the design specification, see [dots-design.md](dots-design.md). +The docs above are the source of truth for dots behavior; [dots-design.md](dots-design.md) is the original design record. Contributor docs (architecture, testing, errors, schemas) live in [docs/development/](docs/development/); agent guidance is in [AGENTS.md](AGENTS.md). diff --git a/Taskfile.yml b/Taskfile.yml index daf23d3..ae22f5f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,6 +5,14 @@ version: "3" vars: IMAGE_NAME: dots-test:dev IMAGE_NAME_DEB: dots-test:deb + IMAGE_NAME_MACHINE: dots-test:machine + IMAGE_NAME_PROFILES: dots-test:profiles + # Interactive shells mount the host's ~/.ssh read-only; entry-live.sh copies + # it into the container so ssh accepts the keys. See containers/README.md. + SHELL_RUN: >- + podman run --rm -it + -v "{{env "HOME"}}/.ssh:/mnt/ssh:ro" + --entrypoint /usr/local/lib/dots-test/entry-live.sh tasks: run: go run ./cmd/dots {{.CLI_ARGS}} @@ -36,7 +44,7 @@ tasks: podman:build: desc: "Build the dots-test:dev smoke image into local podman storage." cmds: - - podman build -t {{.IMAGE_NAME}} . + - podman build --target runtime -t {{.IMAGE_NAME}} . sources: - Taskfile.yml - Containerfile @@ -51,16 +59,61 @@ tasks: deps: [podman:build] cmds: - podman run --rm {{.IMAGE_NAME}} - podman:shell: - desc: "Drop into a bash shell inside the dots-test:dev image (debug)." + build:machine: + desc: "Build the clean-machine image (dots + git + ssh, nothing installed)." + cmds: + - podman build --target machine -t {{.IMAGE_NAME_MACHINE}} . + sources: + - Taskfile.yml + - Containerfile + - .containerignore + - cmd/**/*.go + - pkg/**/*.go + - go.mod + - go.sum + - containers/**/* + build:profiles: + desc: "Build the profiles-fixture image." + cmds: + - podman build --target profiles -t {{.IMAGE_NAME_PROFILES}} . + sources: + - Taskfile.yml + - Containerfile + - .containerignore + - cmd/**/*.go + - pkg/**/*.go + - go.mod + - go.sum + - containers/**/* + shell: + desc: "Shell into a clean machine (no taps, no packages). Your ~/.ssh is mounted." + deps: [build:machine] + cmds: + - '{{.SHELL_RUN}} {{.IMAGE_NAME_MACHINE}} bash -l' + shell:fixture: + desc: "Shell into the smoke fixture image (tap bootstrapped, packages installed)." deps: [podman:build] cmds: - - podman run --rm -it --entrypoint bash {{.IMAGE_NAME}} + - '{{.SHELL_RUN}} {{.IMAGE_NAME}} bash -l' + shell:profiles: + desc: "Shell into the profiles fixture (bootstrapped, no profile applied yet)." + deps: [build:profiles] + cmds: + - '{{.SHELL_RUN}} {{.IMAGE_NAME_PROFILES}} bash -l' + podman:test:profiles: + desc: "Run the profiles fixture checks (executes containers/verify-profiles.sh)." + deps: [build:profiles] + cmds: + - podman run --rm {{.IMAGE_NAME_PROFILES}} + podman:shell: + desc: "Alias for `task shell:fixture`." + cmds: + - task: shell:fixture podman:build:deb: desc: "Build the dots-test image from the .deb produced by `task package:deb`." deps: [package:deb] cmds: - - podman build --build-arg BUILD_FROM=deb -t {{.IMAGE_NAME_DEB}} . + - podman build --target runtime --build-arg BUILD_FROM=deb -t {{.IMAGE_NAME_DEB}} . podman:test:deb: desc: "Run the dots-test image built from the .deb (executes containers/verify.sh)." deps: [podman:build:deb] diff --git a/containers/README.md b/containers/README.md index ba74b2e..597f692 100644 --- a/containers/README.md +++ b/containers/README.md @@ -29,12 +29,68 @@ Or via Taskfile: ```bash task podman:build task podman:test -task podman:shell +task podman:test:profiles ``` -`task podman:test` and `task podman:shell` declare `podman:build` as a -dependency, so they will (re)build the image on demand. The `sources:` cache -on `podman:build` skips rebuilds when nothing relevant has changed. +Each task declares its build as a dependency, so images are (re)built on +demand. The `sources:` cache skips rebuilds when nothing relevant has changed. + +## Manual testing + +Three interactive shells, each mounting your `~/.ssh`: + +| Task | Image | State inside | +| --------------------- | --------------------- | ----------------------------------------------------------- | +| `task shell` | `dots-test:machine` | Clean machine: dots, git, ssh, no taps, nothing installed | +| `task shell:fixture` | `dots-test:dev` | Synthetic fixture tap bootstrapped, all packages installed | +| `task shell:profiles` | `dots-test:profiles` | Profiles fixture bootstrapped, no profile applied yet | + +`task shell` is the one to use for a real bootstrap: + +```bash +task shell +# inside the container: +dots init --from git@github.com:jlrickert/dotfiles.git --path dots-config --name jlrickert +dots status +dots profile show work +dots profile apply work --dry-run +``` + +### How the ssh mount works + +The shell tasks mount `~/.ssh` read-only at `/mnt/ssh`, and +`containers/entry-live.sh` copies it to `~/.ssh` inside the container before +handing you a shell. The copy exists because rootless podman maps host-owned +files to root inside the container, and ssh refuses a private key it does not +own. Nothing is written back to the host, and the copy dies with the +container. + +Notes: + +- Key files must be on disk. Agent forwarding does not reach the podman VM on + macOS. A passphrase-protected key still works — you get the prompt. +- The container's `known_hosts` is a writable copy, so + `StrictHostKeyChecking` prompts behave normally. +- `podman machine` mounts `/Users` on macOS, which is what makes the mount + reachable at all. + +## Profiles fixture + +`containers/fixture-profiles/` is a second tap built around the dots package +pattern from [docs/bootstrapping.md](../docs/bootstrapping.md): `dots-config` +links `config.yaml` plus three profile files, and sets +`core.always_installed: [fixture/dots-config]` so applying a profile can never +uninstall the package that owns the config. + +| Profile | Definition | +| ---------- | -------------------------------------------------------- | +| `base` | `shell-basic`, `tmux-ish` | +| `work` | extends `base`, adds `zellij-ish`, excludes `tmux-ish` | +| `personal` | extends `base` | + +`verify-profiles.sh` applies `work`, then `personal`, and asserts that the +reconcile removes what left the profile, installs what joined it, keeps the +always-installed bootstrap package, and that `--dry-run` changes nothing. ## What gets exercised diff --git a/containers/entry-live.sh b/containers/entry-live.sh new file mode 100755 index 0000000..6f5b525 --- /dev/null +++ b/containers/entry-live.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# entry-live.sh — entrypoint for the interactive `task shell*` targets. +# +# The shell tasks mount the host's ~/.ssh read-only at /mnt/ssh. Under +# rootless podman those files appear owned by root inside the container while +# we run as `tester`, and ssh refuses to use a private key it does not own. +# Copying the mount into $HOME/.ssh fixes ownership and leaves known_hosts +# writable. Nothing is ever written back to the host. +set -euo pipefail + +if [ -d /mnt/ssh ] && [ ! -d "${HOME}/.ssh" ]; then + sudo cp -r /mnt/ssh "${HOME}/.ssh" + sudo chown -R "$(id -u):$(id -g)" "${HOME}/.ssh" + chmod 700 "${HOME}/.ssh" + find "${HOME}/.ssh" -maxdepth 1 -type f ! -name '*.pub' -exec chmod 600 {} + +fi + +if [ "$#" -eq 0 ]; then + set -- bash -l +fi +exec "$@" diff --git a/containers/fixture-profiles/dots-config/Dotfile.yaml b/containers/fixture-profiles/dots-config/Dotfile.yaml new file mode 100644 index 0000000..c8fb969 --- /dev/null +++ b/containers/fixture-profiles/dots-config/Dotfile.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/dotfile.json +# +# Bootstrap package for the profiles fixture. It links dots' own config plus +# the profile definitions, mirroring the dots package pattern documented in +# docs/bootstrapping.md. Profile files are linked individually because +# directory links are not supported yet. +package: + name: dots-config + description: Bootstrap package for the profiles fixture tap + version: 0.1.0 + tags: + - dots + - meta +links: + config.yaml: '@config/dots/config.yaml' + profiles/base.yaml: '@config/dots/profiles/base.yaml' + profiles/work.yaml: '@config/dots/profiles/work.yaml' + profiles/personal.yaml: '@config/dots/profiles/personal.yaml' diff --git a/containers/fixture-profiles/dots-config/config.yaml b/containers/fixture-profiles/dots-config/config.yaml new file mode 100644 index 0000000..c7af4c7 --- /dev/null +++ b/containers/fixture-profiles/dots-config/config.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/dots-config.json +# +# always_installed keeps this bootstrap package installed no matter which +# profile is applied — without it, applying a profile that omits dots-config +# would uninstall the package that owns this file. See +# docs/profiles.md#always-installed-packages. +core: + conflict_strategy: prompt + backup: true + link_strategy: copy + always_installed: + - fixture/dots-config diff --git a/containers/fixture-profiles/dots-config/profiles/base.yaml b/containers/fixture-profiles/dots-config/profiles/base.yaml new file mode 100644 index 0000000..3c80a60 --- /dev/null +++ b/containers/fixture-profiles/dots-config/profiles/base.yaml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/profile.json +name: base +packages: + - fixture/shell-basic + - fixture/tmux-ish diff --git a/containers/fixture-profiles/dots-config/profiles/personal.yaml b/containers/fixture-profiles/dots-config/profiles/personal.yaml new file mode 100644 index 0000000..32443bb --- /dev/null +++ b/containers/fixture-profiles/dots-config/profiles/personal.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/profile.json +name: personal +extends: [base] diff --git a/containers/fixture-profiles/dots-config/profiles/work.yaml b/containers/fixture-profiles/dots-config/profiles/work.yaml new file mode 100644 index 0000000..6a8a055 --- /dev/null +++ b/containers/fixture-profiles/dots-config/profiles/work.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/profile.json +# +# Inherits base but swaps tmux for zellij, exercising exclude. +name: work +extends: base +packages: + - fixture/zellij-ish +exclude: + - fixture/tmux-ish diff --git a/containers/fixture-profiles/shell-basic/Dotfile.yaml b/containers/fixture-profiles/shell-basic/Dotfile.yaml new file mode 100644 index 0000000..8852e8c --- /dev/null +++ b/containers/fixture-profiles/shell-basic/Dotfile.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/dotfile.json +package: + name: shell-basic + description: Profiles fixture package (shell-basic) + version: 0.1.0 +links: + rc: '@config/shell-basic/rc' diff --git a/containers/fixture-profiles/shell-basic/rc b/containers/fixture-profiles/shell-basic/rc new file mode 100644 index 0000000..f975b6f --- /dev/null +++ b/containers/fixture-profiles/shell-basic/rc @@ -0,0 +1 @@ +shell-basic rc from the profiles fixture diff --git a/containers/fixture-profiles/tmux-ish/Dotfile.yaml b/containers/fixture-profiles/tmux-ish/Dotfile.yaml new file mode 100644 index 0000000..a2c6765 --- /dev/null +++ b/containers/fixture-profiles/tmux-ish/Dotfile.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/dotfile.json +package: + name: tmux-ish + description: Profiles fixture package (tmux-ish) + version: 0.1.0 +links: + rc: '@config/tmux-ish/rc' diff --git a/containers/fixture-profiles/tmux-ish/rc b/containers/fixture-profiles/tmux-ish/rc new file mode 100644 index 0000000..017bc2a --- /dev/null +++ b/containers/fixture-profiles/tmux-ish/rc @@ -0,0 +1 @@ +tmux-ish rc from the profiles fixture diff --git a/containers/fixture-profiles/zellij-ish/Dotfile.yaml b/containers/fixture-profiles/zellij-ish/Dotfile.yaml new file mode 100644 index 0000000..b15959e --- /dev/null +++ b/containers/fixture-profiles/zellij-ish/Dotfile.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/dotfile.json +package: + name: zellij-ish + description: Profiles fixture package (zellij-ish) + version: 0.1.0 +links: + rc: '@config/zellij-ish/rc' diff --git a/containers/fixture-profiles/zellij-ish/rc b/containers/fixture-profiles/zellij-ish/rc new file mode 100644 index 0000000..f64826a --- /dev/null +++ b/containers/fixture-profiles/zellij-ish/rc @@ -0,0 +1 @@ +zellij-ish rc from the profiles fixture diff --git a/containers/verify-profiles.sh b/containers/verify-profiles.sh new file mode 100755 index 0000000..71f5d5b --- /dev/null +++ b/containers/verify-profiles.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# verify-profiles.sh — entrypoint for the dots-test:profiles image. Exercises +# the profile behavior specced in docs/profiles.md against the fixture tap +# under /opt/dotfiles-profiles. +# +# Layout under test (set by the Containerfile): +# - tap "fixture" registered via `dots init --from file:///opt/dotfiles-profiles --path dots-config` +# - dots-config links config.yaml plus profiles/{base,work,personal}.yaml +# - core.always_installed = [fixture/dots-config] +# - no profile applied yet +# - link_strategy=copy, so installed files land as regular files +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh disable=SC1091 +. "${HERE}/lib.sh" + +cd "${HOME}" + +CONFIG="${HOME}/.config" +SHELL_RC="${CONFIG}/shell-basic/rc" +TMUX_RC="${CONFIG}/tmux-ish/rc" +ZELLIJ_RC="${CONFIG}/zellij-ish/rc" + +# --- Phase 0: bootstrap state --- +assert_cmd dots +assert_file "${CONFIG}/dots/config.yaml" +assert_file "${CONFIG}/dots/profiles/work.yaml" +assert_cmd_stdout_contains "dots list shows the bootstrap package" "fixture/dots-config" dots list + +# --- Phase 1: resolution --- +# always_installed is part of every profile; work excludes the inherited +# tmux-ish and adds zellij-ish. +assert_cmd_stdout_contains "show work: dots-config comes from always_installed" \ + "fixture/dots-config (always_installed)" dots profile show work +assert_cmd_stdout_contains "show work: shell-basic inherited from base" \ + "fixture/shell-basic (base)" dots profile show work +assert_cmd_stdout_contains "show work: zellij-ish from work" \ + "fixture/zellij-ish (work)" dots profile show work +# Scope the check to the Resolved section: tmux-ish legitimately appears +# above it on the "Exclude:" line. +if dots profile show work | sed -n '/^Resolved:/,$p' | grep -qF "fixture/tmux-ish"; then + fail "show work: excluded tmux-ish should not appear in the resolved list" +else + pass "show work: excluded tmux-ish is absent from the resolved list" +fi + +assert_cmd_stdout_contains "show work: exclude is listed" "Exclude: fixture/tmux-ish" dots profile show work + +# --- Phase 2: apply work --- +assert_cmd_exits_zero "dots profile apply work" dots profile apply work +assert_file "${SHELL_RC}" +assert_file "${ZELLIJ_RC}" +assert_absent "${TMUX_RC}" +assert_file "${CONFIG}/dots/config.yaml" +assert_cmd_stdout_contains "apply work keeps the bootstrap package" "fixture/dots-config" dots list +assert_cmd_stdout_contains "status reports work as active" "work" dots status + +# --- Phase 3: dry run changes nothing --- +assert_cmd_stdout_contains "apply personal --dry-run previews the removal" \ + "remove fixture/zellij-ish" dots profile apply personal --dry-run +assert_file "${ZELLIJ_RC}" +assert_absent "${TMUX_RC}" +assert_cmd_stdout_contains "status still reports work after a dry run" "work" dots status + +# --- Phase 4: apply personal reconciles back --- +assert_cmd_exits_zero "dots profile apply personal" dots profile apply personal +assert_file "${TMUX_RC}" +assert_absent "${ZELLIJ_RC}" +assert_file "${SHELL_RC}" +assert_file "${CONFIG}/dots/config.yaml" +assert_cmd_stdout_contains "apply personal keeps the bootstrap package" "fixture/dots-config" dots list +assert_cmd_stdout_contains "status reports personal as active" "personal" dots status + +# --- Phase 5: re-applying is a no-op --- +assert_cmd_stdout_contains "re-applying personal changes nothing" \ + "0 removed, 0 installed" dots profile apply personal + +summary diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md index b37376c..553dc4b 100644 --- a/docs/bootstrapping.md +++ b/docs/bootstrapping.md @@ -27,16 +27,27 @@ dots install personal/nvim If you already have a dotfiles repo, bootstrap everything in one command: ```bash -dots init --from git@github.com:you/dotfiles.git --path dots +dots init --from git@github.com:you/dotfiles.git --path dots --apply work ``` This: 1. Clones the repo as a tap 2. Installs the package at the specified `--path` within the tap -3. The installed package typically contains dots' own config, completing the bootstrap +3. The installed package typically contains dots' own config and profiles, completing the bootstrap +4. Applies the `work` profile (see [Applying a Profile](profiles.md#applying-a-profile)) -After the initial bootstrap, apply a profile to install the rest: +`--apply` runs only after the bootstrap package is installed, so profiles shipped by that package are available to it. If the profile can't be found, `init` reports the error after the bootstrap has already succeeded; fix the profile and run `dots profile apply work`. + +`--apply` also works without `--from`, on a machine where the profile already exists: + +```bash +dots init --apply work +``` + +`apply` uninstalls every package that isn't in the profile. To keep the bootstrap package installed, list it in `core.always_installed` in the config it ships (see [The `dots` Package Pattern](#the-dots-package-pattern)). `init` reloads config after installing the bootstrap package, so `--apply` sees that setting. + +You can also skip `--apply` and apply a profile later: ```bash dots profile apply work @@ -51,6 +62,9 @@ taps/personal/ dots/ Dotfile.yaml config.yaml + profiles/ + base.yaml + work.yaml nvim/ Dotfile.yaml init.lua @@ -67,9 +81,28 @@ package: links: config.yaml: "@config/dots/config.yaml" + profiles/base.yaml: "@config/dots/profiles/base.yaml" + profiles/work.yaml: "@config/dots/profiles/work.yaml" +``` + +The `config.yaml` inside the dots package contains your tap registrations, aliases, and preferences. The profile files define which packages each kind of machine gets. When `dots init --from --path dots` installs this package, it links both into place, giving dots full knowledge of your taps, settings, and profiles, which is what lets `--apply` work in the same command. + +Link each profile file individually; directory links (a trailing `/`) are not supported yet. + +The package's `config.yaml` should list the package itself in `core.always_installed`, so applying any profile keeps it installed: + +```yaml +# dots/config.yaml +core: + always_installed: + - personal/dots + +taps: + personal: + url: git@github.com:you/dotfiles.git ``` -The `config.yaml` inside the dots package contains your tap registrations, aliases, and preferences. When `dots init --from --path dots` installs this package, it links your config.yaml into place, giving dots full knowledge of your taps and settings. +Without this, every profile would have to list `personal/dots`, and applying one that didn't would uninstall the package that defines your config and profiles. See [Always-Installed Packages](profiles.md#always-installed-packages). ## Recovery @@ -79,8 +112,8 @@ If dots is installed but your config is missing or corrupted: # Re-initialize with defaults dots init -# Or re-bootstrap from your repo -dots init --from git@github.com:you/dotfiles.git --path dots +# Or re-bootstrap from your repo and re-apply your profile +dots init --from git@github.com:you/dotfiles.git --path dots --apply work ``` If you have a profile exported: @@ -124,11 +157,8 @@ For fully automated setup on a new machine, create a small bootstrap script: # Install dots (via Homebrew or from source) brew install jlrickert/formulae/dots -# Bootstrap from your dotfiles repo -dots init --from git@github.com:you/dotfiles.git --path dots - -# Apply your profile -dots profile apply work +# Bootstrap from your dotfiles repo and apply your profile +dots init --from git@github.com:you/dotfiles.git --path dots --apply "${1:-work}" ``` Save this somewhere accessible (e.g., a GitHub gist) so you can run it on any new machine. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 94c2c0d..3070347 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -24,6 +24,7 @@ Initialize the dots environment. Creates a default config file if one doesn't ex dots init dots init --from git@github.com:you/dotfiles.git --path dots dots init --from git@github.com:you/dot-personal.git --name private --path dots-config +dots init --from git@github.com:you/dotfiles.git --path dots --apply work ``` | Flag | Description | @@ -31,9 +32,12 @@ dots init --from git@github.com:you/dot-personal.git --name private --path dots- | `--from` | Git URL to clone as initial tap | | `--path` | Package path within the tap to install | | `--name` | Tap name for the `--from` clone (default: `default`). Set this to match the tap name declared in the bootstrap package's `config.yaml` so the on-disk tap and the installed config agree. | +| `--apply` | Profile to apply after initialization, with the same behavior as [`dots profile apply`](#dots-profile-apply). | When `--from` is specified, dots clones the repo as a tap and installs the package at `--path` from it. +When `--apply` is specified, dots applies the named profile after everything else, so profiles installed by the bootstrap package are available. If the profile is missing, the command fails after the bootstrap has already completed. Config is reloaded after the bootstrap install, so `core.always_installed` from the bootstrap package's config applies. List the bootstrap package there, or `apply` uninstalls it. See [Bootstrapping](bootstrapping.md#one-command-bootstrap). + If the bootstrap package (the one at `--path`) ships a `config.yaml` that declares the same repo as a named tap (e.g. `taps.private.url` matching `--from`), pass `--name private` so dots registers the clone under `private` rather than the generic `default`. Otherwise dots ends up with two taps — `default` and `private` — pointing at the same URL. ### Cleaning up a duplicate tap @@ -243,8 +247,13 @@ Create a new empty profile. ```bash dots profile create work +dots profile create work --extends base --extends dev ``` +| Flag | Description | +| ----------- | ---------------------------------------------- | +| `--extends` | Parent profile to inherit from. Repeatable. | + ### `dots profile delete` Delete a profile. @@ -263,7 +272,7 @@ dots profile list ### `dots profile show` -Show profile details (name, extends, packages). +Show profile details: name, `extends` parents, `exclude` list, and the resolved package list with the source of each package. Flags excludes that match no inherited package. ```bash dots profile show work @@ -285,22 +294,51 @@ Remove a package from a profile. dots profile remove work personal/git ``` -### `dots profile apply` +### `dots profile extend` -Install all packages in a profile. +Add parent profiles to a profile's `extends` list. ```bash -dots profile apply work +dots profile extend work base dev +``` + +### `dots profile unextend` + +Remove parent profiles from a profile's `extends` list. + +```bash +dots profile unextend work dev +``` + +### `dots profile exclude` + +Exclude inherited packages from a profile. Excludes only affect packages inherited through `extends`; the profile's own packages are always installed. See [Excluding Inherited Packages](profiles.md#excluding-inherited-packages). + +```bash +dots profile exclude work personal/tmux ``` -### `dots profile switch` +### `dots profile unexclude` -Switch to a different profile (sets it as active). +Remove packages from a profile's `exclude` list. ```bash -dots profile switch personal +dots profile unexclude work personal/tmux ``` +### `dots profile apply` + +Make the installed packages match a profile exactly: uninstall every installed package not in the profile's resolved list plus `core.always_installed` (including packages installed by hand), install the missing ones, and record the profile as active in the lockfile. This is also how you change profiles. + +```bash +dots profile apply work +dots profile apply personal --dry-run +``` + +| Flag | Description | +| ----------- | ------------------------------------------------------------------ | +| `--dry-run` | Print the packages that would be removed and installed, then exit. | + ### `dots profile export` Export a profile as YAML to stdout. diff --git a/docs/config-yaml.md b/docs/config-yaml.md index e10bf82..2b81017 100644 --- a/docs/config-yaml.md +++ b/docs/config-yaml.md @@ -31,19 +31,22 @@ Core behavior settings. | Field | Type | Default | Description | | ------------------- | ------- | ----------- | --------------------------------------------------------------------- | -| `active_profile` | string | `""` | Currently active [profile](profiles.md) name | | `conflict_strategy` | string | `"prompt"` | How to handle file conflicts: `prompt`, `overwrite`, `skip`, `backup` | | `backup` | boolean | `true` | Back up existing files before overwriting | | `link_strategy` | string | `"symlink"` | Default link strategy: `symlink`, `copy`, `hardlink` | +| `always_installed` | list | `[]` | Packages (`tap/package`) included in every applied [profile](profiles.md#always-installed-packages); cannot be excluded | + +The active [profile](profiles.md) is not stored in config; it is tracked per-machine in the lockfile. See [Active Profile](profiles.md#active-profile). See [Link Strategies - Backup Behavior](link-strategies.md#backup-behavior) for details on each `conflict_strategy`. ```yaml core: - active_profile: work conflict_strategy: backup backup: true link_strategy: symlink + always_installed: + - personal/dots ``` ## `git` Section @@ -124,7 +127,15 @@ platform: link_strategy: symlink ``` -Platform overrides follow the same cascade as [Dotfile.yaml](dotfile-yaml.md): **base core -> OS -> OS-arch**, with more specific values winning. +Platform overrides follow the same cascade as [Dotfile.yaml](dotfile-yaml.md): **base core -> OS -> OS-arch**, with more specific values winning. Lists concatenate instead: `always_installed` entries from a platform block are added to the base list (deduped), not substituted for it. + +```yaml +core: + always_installed: [personal/dots] +platform: + darwin: + always_installed: [personal/brew-bundle] # macOS gets dots + brew-bundle +``` ## Defaults @@ -146,10 +157,11 @@ git: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/dots-config.json core: - active_profile: work conflict_strategy: backup backup: true link_strategy: symlink + always_installed: + - personal/dots git: default_branch: main diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..e3e8e9a --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,77 @@ +# Architecture + +Internal structure of the dots codebase. Behavior is defined by the user docs in [docs/](../); this page describes how the code implements them. [dots-design.md](../../dots-design.md) is the original design record. + +## Package Map + +- **`cmd/dots/`** — Binary entrypoint: `toolkit.NewRuntime()` → `cli.Run()` +- **`pkg/cli/`** — Cobra command definitions bridging CLI flags to `pkg/dotsctl`. Each command in its own `cmd_*.go` file. +- **`pkg/dots/`** — Core library: platform detection, path aliases, cascade merge, config/manifest parsing, repository interface, error types, and the artifact subsystem (`Artifact`, `Fetcher`/`HTTPFetcher`/`MemoryFetcher`, `Extractor` with zip-slip / tar-slip hardening). The fetcher and extractor route all FS I/O through `*toolkit.Runtime`; `HTTPFetcher` additionally takes an `*http.Client` because cli-toolkit does not yet expose an HTTP seam. +- **`pkg/dotsctl/`** — Service layer: `Dots` struct composing `PathService`, `ConfigService`, and `Repository`. Each operation in its own `dots_*.go` file (file-per-operation pattern). + +## Key Types and Flow + +``` +CLI command → pkg/cli (Cobra) → pkg/dotsctl.Dots → pkg/dots.Repository +``` + +**Dots** (`pkg/dotsctl/dots.go`) is the root service struct composing: + +- `PathService` — platform-native directory resolution +- `ConfigService` — cached config loading with defaults fallback +- `Repository` — storage backend (MemoryRepo for tests, FsRepo for production) + +**Repository** (`pkg/dots/repository.go`) is the storage contract with: + +- `MemoryRepo` (`repo_memory.go`) — in-memory, thread-safe, used in tests +- `FsRepo` (`repo_fs.go`) — filesystem-backed (skeleton, not yet implemented) + +**Platform** (`pkg/dots/platform.go`) detects OS-arch at runtime and resolves path aliases (`@config`, `@data`, `@home`, `@cache`, `@state`, `@bin`) to platform-native paths. + +**AliasResolver** (`pkg/dots/platform.go`) expands alias paths with XDG support on Unix and `%APPDATA%`/`%LOCALAPPDATA%` on Windows. Supports custom user aliases with chaining. See [Path Aliases](../path-aliases.md). + +**DeepMerge / ResolvePlatformCascade** (`pkg/dots/platform_cascade.go`) merges config sections in specificity order: base → OS → OS-arch. Maps deep-merge, scalars replace, lists concatenate-dedup. See [Platform System](../platform-system.md). + +## File-Per-Operation Pattern + +Service operations follow a consistent pattern in `pkg/dotsctl/`: + +``` +dots.go # Dots struct, NewDots(), DotsOptions +dots_init.go # InitOptions + (*Dots).Init() +dots_list.go # ListOptions + (*Dots).List() +dots_status.go # StatusResult + (*Dots).Status() +dots_doctor.go # DoctorCheck + (*Dots).Doctor() +``` + +Each file contains an `Options` struct and a method on `*Dots`. New operations are added by creating a new `dots_.go` file. + +## Config Hierarchy + +- User config: `~/.config/dots/config.yaml` (Unix) or `%APPDATA%\dots\config.yaml` (Windows) +- Defaults: `dots.DefaultConfig()` provides sensible fallbacks; a missing config file is not an error. +- Platform overrides: `config.yaml` `platform:` section applies per-OS/arch overrides + +Config is loaded by `ConfigService` in `pkg/dotsctl/config_service.go`. See [config.yaml Reference](../config-yaml.md). + +## Manifest Resolution + +`Dotfile.yaml` manifests support platform cascade resolution: + +```yaml +links: + init.lua: "@config/nvim/init.lua" # base +platform: + darwin: + links: + clipboard.lua: "@config/nvim/lua/clipboard.lua" # OS override + darwin-arm64: + links: + bin/nvim-arm: "@bin/nvim-silicon" # OS-arch override +``` + +`dots.ResolveManifest(manifest, platform)` produces the effective manifest for the current platform. See [Dotfile.yaml Reference](../dotfile-yaml.md). + +## Dependency: cli-toolkit + +`github.com/jlrickert/cli-toolkit` provides `toolkit.Runtime` — the explicit dependency container carrying filesystem, env, clock, logger, hasher, stream, and process identity. All I/O flows through Runtime, enabling sandboxed test environments. diff --git a/docs/development/errors.md b/docs/development/errors.md new file mode 100644 index 0000000..6573758 --- /dev/null +++ b/docs/development/errors.md @@ -0,0 +1,25 @@ +# Error Handling + +## Sentinel Errors + +Defined in `pkg/dots/errors.go`: + +- `ErrNotExist` +- `ErrExist` +- `ErrParse` +- `ErrConflict` +- `ErrChecksumMismatch` +- `ErrUnsupportedExtract` + +## Typed Errors + +- `TapNotFoundError` +- `PackageNotFoundError` +- `InvalidConfigError` + +`TapNotFoundError` and `PackageNotFoundError` both satisfy `errors.Is(err, ErrNotExist)`. + +## Rules + +- Check sentinels with `errors.Is()` and typed errors with `errors.As()`. +- Never compare error strings. diff --git a/docs/development/schemas.md b/docs/development/schemas.md new file mode 100644 index 0000000..4235bf5 --- /dev/null +++ b/docs/development/schemas.md @@ -0,0 +1,22 @@ +# JSON Schemas + +## Files + +- `schemas/dotfile.json` — Draft 7 schema for `Dotfile.yaml` package manifests. +- `schemas/dots-config.json` — Draft 7 schema for `config.yaml` user configuration. +- `schemas/profile.json` — Draft 7 schema for profile definitions in `~/.config/dots/profiles/.yaml`. + +## Modelines + +- Schema URL constants and modeline strings live in `pkg/dots/config.go`, `pkg/dots/manifest.go`, and `pkg/dots/profile.go`. +- `ConfigService.Save` and `dots init` prepend the `yaml-language-server` modeline to written config files. +- `MarshalProfile` does the same for profiles, so all profile writers (`ProfileCreate`, `ProfileExport`, internal `saveProfile`) inherit it. +- `DotfileSchemaModeline` is defined but not auto-injected — dots does not currently scaffold `Dotfile.yaml` files. Use it when adding such a command. + +## Keeping Schemas in Sync + +Fields flow doc → schema → struct: + +1. Document the field in [config-yaml.md](../config-yaml.md), [dotfile-yaml.md](../dotfile-yaml.md), or [profiles.md](../profiles.md). +2. Add it to the matching JSON Schema in `schemas/`. +3. Add it to the `Config`, `Manifest`, or `Profile` Go struct. diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000..677826e --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,49 @@ +# Testing + +## Commands + +```bash +go test ./... # all tests +go test ./pkg/dots/... -v # single package, verbose +go test ./pkg/dots -run TestAliasResolver # single test by name +go test -race ./pkg/dots/... # with race detector +``` + +## Patterns + +- **Sandbox pattern**: CLI tests use `sandbox.NewSandbox(t, ...)` from cli-toolkit with a jailed temp directory and test runtime. +- **Fixtures**: `pkg/cli/data/` contains embedded test fixtures (config, tap manifests). +- **RunCommand harness**: `RunCommand(t, sb, args...)` in `pkg/cli/testhelpers_test.go` runs dots CLI commands and returns stdout/stderr/exitCode. +- **MemoryRepo for speed**: Service layer tests use `dots.NewMemoryRepo()`. Use FsRepo + sandbox only when testing filesystem behavior. +- **Testify**: Uses `github.com/stretchr/testify/require` for assertions. +- **Windows paths**: Windows path tests use forward slashes since `filepath.Join` on non-Windows hosts won't produce backslashes. + +## Container Smoke Tests + +End-to-end smoke tests run the real binary inside a podman image and execute `containers/verify.sh`: + +```bash +task podman:test # smoke image: verify.sh (fixture tap + installs) +task podman:test:profiles # profiles fixture: verify-profiles.sh +task package:deb # build .deb artifacts via goreleaser snapshot +task podman:test:deb # run verify.sh against the .deb image +``` + +## Manual Testing + +Interactive shells, each with your `~/.ssh` mounted so real `git@…` clones +work: + +```bash +task shell # clean machine: no taps, nothing installed +task shell:fixture # smoke fixture, bootstrapped and installed +task shell:profiles # profiles fixture, nothing applied yet +``` + +Use `task shell` to exercise a real bootstrap end to end (`dots init --from +git@github.com:… --path dots-config --name `). + +Note for anyone editing the Containerfile: `profiles` is the last stage, so +every build must pass an explicit `--target` (`runtime` for the smoke image). + +See [containers/README.md](../../containers/README.md) for details. diff --git a/docs/development/workflow.md b/docs/development/workflow.md new file mode 100644 index 0000000..a47656e --- /dev/null +++ b/docs/development/workflow.md @@ -0,0 +1,26 @@ +# Docs-First Workflow + +The documentation in [docs/](../) is the source of truth for how dots behaves. Code follows the docs, not the other way around. [dots-design.md](../../dots-design.md) is the original design record; where it conflicts with `docs/`, `docs/` wins. + +## Making a Behavior Change + +1. **Doc** — Update or add the user-facing doc (`docs/*.md`) describing the new behavior: fields, flags, examples, edge cases. +2. **Schema** — If the doc adds or changes a `Config`, `Manifest`, or `Profile` field, update the matching file in `schemas/`. See [schemas.md](schemas.md). +3. **Code** — Implement in `pkg/dots` / `pkg/dotsctl` / `pkg/cli` so it matches the doc. +4. **Tests** — Write tests that exercise the documented examples. See [testing.md](testing.md). +5. **Status** — Update the feature status table in [README.md](../../README.md): mark the row Done, or add a Partial/Planned row if the doc is ahead of the code. + +## Resolving Drift + +When code and docs disagree: + +- Assume the doc is correct and fix the code. +- If the doc is actually wrong, change the doc on purpose as a spec change (`docs:` commit) and then bring the code in line. +- If the code can't catch up yet, keep the doc as written and record the gap in the README status table as Partial or Planned. +- Never quietly edit a doc to describe what the code happens to do. + +## Commits + +- Spec changes use `docs:`. +- The `feat:` / `fix:` that implements a doc can land in the same commit or a follow-up. +- Conventional commits, summary ≤72 chars, no co-author lines. diff --git a/docs/profiles.md b/docs/profiles.md index 0b729c9..22dbdfb 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -1,6 +1,6 @@ # Profiles -Profiles are named collections of packages that can be applied together. They let you maintain different sets of dotfiles for different contexts (e.g., work vs personal machines) and switch between them. +Profiles are named collections of packages that can be applied together. They let you maintain different sets of dotfiles for different contexts (e.g., work vs personal machines) and move between them with a single command. ## Creating a Profile @@ -14,33 +14,56 @@ This creates an empty profile. Add packages to it: dots profile add work personal/zsh personal/nvim personal/git personal/tmux ``` -## Applying a Profile +A profile can inherit from one or more other profiles at creation time: + +```bash +dots profile create work --extends base --extends dev +``` -Install all packages in a profile: +See [Inheritance with `extends`](#inheritance-with-extends). + +## Applying a Profile ```bash dots profile apply work ``` -This installs every package listed in the profile that isn't already installed. +`apply` makes the installed packages match the profile exactly: -## Switching Profiles +1. Resolves the profile's full package list (following `extends` and `exclude`, plus [always-installed packages](#always-installed-packages)). +2. Uninstalls every installed package that is **not** in that list. +3. Installs every package in the list that isn't already installed. +4. Records the profile as the active profile in the lockfile. -Switch the active profile: +Packages already installed and still in the list are left alone. Running `apply` again with the same profile is a no-op when nothing has changed; running it with a different profile is how you change profiles. + +Preview the changes without touching anything: ```bash -dots profile switch personal +dots profile apply work --dry-run ``` -This sets `core.active_profile` in your config. It does not automatically install or remove packages — use `dots profile apply` after switching to install the new profile's packages. +The dry run prints the packages that would be removed and installed. + +> **Warning:** `apply` removes _every_ installed package that isn't in the profile, including packages installed by hand with `dots install`. Add anything you want to keep to the profile (or a profile it extends) before applying. + +> **Tip:** Packages every profile needs, especially the `dots` package that manages your `config.yaml` and profiles, belong in [`core.always_installed`](#always-installed-packages) instead. Then no profile has to remember them, and `apply` never removes them. ## Managing Packages in Profiles ```bash -dots profile add work personal/docker # add a package -dots profile remove work personal/docker # remove a package +dots profile add work personal/docker # add packages +dots profile remove work personal/docker # remove a package + +dots profile extend work dev # add parent profiles +dots profile unextend work dev # remove parent profiles + +dots profile exclude work personal/tmux # exclude inherited packages +dots profile unexclude work personal/tmux # stop excluding them ``` +Editing a profile does not change what's installed. Run `dots profile apply` to bring the machine in line with the updated profile. + ## Viewing Profiles ```bash @@ -48,22 +71,126 @@ dots profile list # list all profile names dots profile show work # show details of a profile ``` -`show` displays the profile name, any `extends` parent, and the full package list. +`show` displays the profile name, its `extends` parents, its `exclude` list, and the fully resolved package list. Each resolved package is annotated with where it came from (the profile itself, the parent that contributed it, or `always_installed`). Excludes that don't match any inherited package are flagged so stale entries are easy to spot. ## Inheritance with `extends` -A profile can inherit packages from a parent profile: +A profile can inherit packages from one or more parent profiles. `extends` accepts a single name or a list: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/dots/main/schemas/profile.json name: work -extends: base +extends: [base, dev] packages: - personal/slack - personal/docker ``` -When `work` extends `base`, applying `work` installs all packages from `base` plus the packages listed in `work`. The inheritance chain is resolved with deduplication — a package listed in both parent and child only installs once. +```yaml +name: laptop +extends: base # a single parent is also fine +``` + +### Resolution + +A profile's resolved package list is built as follows: + +1. Resolve each parent in `extends`, in the order listed. Parents are resolved recursively, and each parent's own `exclude` list has already been applied to its result. +2. Concatenate the parents' lists, keeping the first occurrence of each package. +3. Remove every package in this profile's `exclude` list. +4. Append this profile's own `packages`, skipping any already present. +5. When the profile is applied, prepend `core.always_installed` from config (see [Always-Installed Packages](#always-installed-packages)), skipping any already present. These can't be excluded. + +Because deduplication keeps the first occurrence, a package shared by several parents (for example, two profiles that both extend `base`) installs once. + +### Errors + +- **Missing parent** — applying or showing a profile fails if any profile in its `extends` chain doesn't exist. +- **Cycles** — a profile that directly or indirectly extends itself is rejected, and the error names the cycle (e.g. `work → dev → work`). + +### Example + +```yaml +# base.yaml +name: base +packages: [personal/zsh, personal/git, personal/tmux] + +# dev.yaml +name: dev +extends: base +packages: [personal/nvim, personal/go] + +# work.yaml +name: work +extends: [base, dev] +packages: [work/zellij] +exclude: [personal/tmux] +``` + +Resolving `work`: + +| Step | Packages | +| ---------------------- | -------------------------------------------------------------------------- | +| `base` | zsh, git, tmux | +| `dev` (extends `base`) | zsh, git, tmux, nvim, go | +| Concatenate + dedupe | zsh, git, tmux, nvim, go | +| Apply `exclude` | zsh, git, nvim, go | +| Append own packages | zsh, git, nvim, go, **zellij** | +| Prepend always-installed (`personal/dots`) | **dots**, zsh, git, nvim, go, zellij | + +## Excluding Inherited Packages + +`exclude` removes packages that a profile would otherwise inherit from its parents. Use it to drop a package that conflicts with something in the child profile, without editing the shared parent: + +```yaml +name: work +extends: base +packages: + - work/zellij +exclude: + - personal/tmux # base ships tmux; work uses zellij instead +``` + +Rules: + +- `exclude` only affects **inherited** packages. A package listed in the profile's own `packages` is always installed, even if it also appears in `exclude`. +- Excludes are inherited like everything else: if `work` excludes `personal/tmux`, a profile that extends `work` won't receive `personal/tmux` from `work`. It can still get it from another parent, or list it in its own `packages`. +- Excluding a package that no parent provides is allowed and has no effect; `dots profile show` flags it. +- `exclude` never removes an [always-installed package](#always-installed-packages). + +## Always-Installed Packages + +Some packages belong on every machine no matter which profile is applied. The most important is the `dots` package that links your `config.yaml` and profile files (see [Bootstrapping](bootstrapping.md#the-dots-package-pattern)). If a profile left it out, applying that profile would uninstall the package that defines the profile. + +List these in `core.always_installed` in [config.yaml](config-yaml.md#core-section): + +```yaml +core: + always_installed: + - personal/dots + - personal/zsh +``` + +Rules: + +- Every `dots profile apply` includes these packages, whichever profile is applied. They are prepended to the resolved list in the order given. +- They override `exclude`: no profile can exclude an always-installed package. +- `apply` never uninstalls them. Removing one with `dots remove` works, but the next `apply` reinstalls it. To stop installing it, remove it from `core.always_installed`. +- Platform overrides add to the list, so a `platform.darwin.always_installed` entry is included only on macOS (see [config.yaml platform section](config-yaml.md#platform-section)). +- `dots profile show` and `apply --dry-run` label these packages as coming from `always_installed`. + +Because the `dots` package usually ships `config.yaml` itself, its config can name the package, and every machine bootstrapped from it gets the protection automatically. + +## Active Profile + +The active profile is the one most recently applied. It is recorded in the lockfile under `state.active_profile`, not in `config.yaml`, so it stays local to each machine and never ends up in a tap: + +| Platform | Lockfile path | +| ------------- | ---------------------------------------------------------------------------------- | +| macOS / Linux | `$XDG_STATE_HOME/dots/dots.lock.yaml` (default `~/.local/state/dots/dots.lock.yaml`) | +| Windows | `%LOCALAPPDATA%\state\dots\dots.lock.yaml` | + +`dots status` reports the active profile. ## Export and Import @@ -79,6 +206,8 @@ Import a profile from a YAML file: dots profile import work-profile.yaml ``` +Export writes the profile as defined (its `extends`, `exclude`, and own `packages`), not the resolved list. Importing a profile that extends others requires those parents to exist before it can be applied. + This is useful for: - Sharing profiles between machines @@ -94,29 +223,37 @@ Profiles are stored as YAML files in the dots config directory under `profiles/` | macOS / Linux | `~/.config/dots/profiles/.yaml` | | Windows | `%APPDATA%\dots\profiles\.yaml` | -They are managed entirely through the `dots profile` commands — you don't need to edit profile files directly. +They are managed through the `dots profile` commands, but they are plain YAML and can also be shipped from a tap (see [Bootstrapping](bootstrapping.md#the-dots-package-pattern)). When dots writes a profile, it prepends a `yaml-language-server` modeline pointing at `schemas/profile.json` so editors can offer schema validation and completion. ## Example Workflow ```bash -# Set up profiles for different machines +# personal/dots is listed in core.always_installed in config.yaml, +# so no profile needs to include it. + +# Shared packages every machine gets dots profile create base -dots profile add base personal/zsh personal/git personal/nvim +dots profile add base personal/zsh personal/git personal/nvim personal/tmux -dots profile create work -# work inherits from base, so you only add work-specific packages -dots profile add work personal/docker personal/slack +# Work machines: everything in base, plus work tools, minus tmux +dots profile create work --extends base +dots profile add work personal/docker work/zellij +dots profile exclude work personal/tmux -dots profile create personal +# Personal machines: everything in base, plus personal extras +dots profile create personal --extends base dots profile add personal personal/gaming personal/media # On a work machine -dots profile apply work # installs base + work packages -dots profile switch work # marks work as active +dots profile apply work --dry-run # preview +dots profile apply work # installs dots + base + work, minus tmux -# On a personal machine -dots profile apply personal # installs base + personal packages -dots profile switch personal +# Later, repurpose the machine +dots profile apply personal # removes docker/zellij, installs tmux/gaming/media ``` + +## Not Yet Specified + +- **Install origin tracking.** dots does not yet record whether a package was installed by a profile or by hand, so `apply` treats them the same and removes anything outside the profile. A future option (e.g. `dots install --keep`) may mark packages that `apply` should leave in place. diff --git a/dots-design.md b/dots-design.md index 89baae0..9d45647 100644 --- a/dots-design.md +++ b/dots-design.md @@ -1,5 +1,7 @@ # dots — System Design (v5) +> Historical design record. The docs in [docs/](docs/) are authoritative; where they differ, docs/ wins. + A brew-style dotfile package manager with taps, profiles, overlays, and cross-platform support. --- diff --git a/pkg/cli/cmd_init.go b/pkg/cli/cmd_init.go index f340699..f60e572 100644 --- a/pkg/cli/cmd_init.go +++ b/pkg/cli/cmd_init.go @@ -1,6 +1,8 @@ package cli import ( + "fmt" + "github.com/jlrickert/dots/pkg/dotsctl" "github.com/spf13/cobra" ) @@ -16,13 +18,20 @@ func newInitCmd(deps *Deps) *cobra.Command { if err != nil { return err } - return d.Init(cmd.Context(), opts) + if err := d.Init(cmd.Context(), opts); err != nil { + return err + } + if opts.Apply != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Applied profile %s\n", opts.Apply) + } + return nil }, } cmd.Flags().StringVar(&opts.From, "from", "", "Git URL to clone as initial tap") cmd.Flags().StringVar(&opts.Path, "path", "", "Package path within the tap") cmd.Flags().StringVar(&opts.Name, "name", "", "Tap name for --from clone (default: \"default\"). Set this to match the tap declared in the bootstrap package's config to avoid a duplicate tap entry.") + cmd.Flags().StringVar(&opts.Apply, "apply", "", "Profile to apply after initialization (same as 'dots profile apply')") return cmd } diff --git a/pkg/cli/cmd_profile.go b/pkg/cli/cmd_profile.go index 0d6d9b9..70c78d5 100644 --- a/pkg/cli/cmd_profile.go +++ b/pkg/cli/cmd_profile.go @@ -3,6 +3,9 @@ package cli import ( "fmt" "os" + "strings" + + "github.com/jlrickert/dots/pkg/dotsctl" "github.com/spf13/cobra" ) @@ -20,8 +23,11 @@ func newProfileCmd(deps *Deps) *cobra.Command { newProfileShowCmd(deps), newProfileAddCmd(deps), newProfileRemoveCmd(deps), + newProfileExtendCmd(deps), + newProfileUnextendCmd(deps), + newProfileExcludeCmd(deps), + newProfileUnexcludeCmd(deps), newProfileApplyCmd(deps), - newProfileSwitchCmd(deps), newProfileExportCmd(deps), newProfileImportCmd(deps), ) @@ -30,7 +36,8 @@ func newProfileCmd(deps *Deps) *cobra.Command { } func newProfileCreateCmd(deps *Deps) *cobra.Command { - return &cobra.Command{ + var extends []string + cmd := &cobra.Command{ Use: "create ", Short: "Create a new profile", Args: cobra.ExactArgs(1), @@ -39,13 +46,20 @@ func newProfileCreateCmd(deps *Deps) *cobra.Command { if err != nil { return err } - if err := d.ProfileCreate(cmd.Context(), args[0]); err != nil { + err = d.ProfileCreate(cmd.Context(), dotsctl.ProfileCreateOptions{ + Name: args[0], + Extends: extends, + }) + if err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Created profile %s\n", args[0]) return nil }, } + cmd.Flags().StringArrayVar(&extends, "extends", nil, "Parent profile to inherit from (repeatable)") + _ = cmd.RegisterFlagCompletionFunc("extends", completeProfileNames(deps)) + return cmd } func newProfileDeleteCmd(deps *Deps) *cobra.Command { @@ -102,19 +116,38 @@ func newProfileShowCmd(deps *Deps) *cobra.Command { if err != nil { return err } - profile, err := d.ProfileShow(cmd.Context(), args[0]) + result, err := d.ProfileShow(cmd.Context(), args[0]) if err != nil { return err } + profile := result.Profile out := cmd.OutOrStdout() fmt.Fprintf(out, "Name: %s\n", profile.Name) - if profile.Extends != "" { - fmt.Fprintf(out, "Extends: %s\n", profile.Extends) + if len(profile.Extends) > 0 { + fmt.Fprintf(out, "Extends: %s\n", strings.Join(profile.Extends, ", ")) + } + if len(profile.Exclude) > 0 { + fmt.Fprintf(out, "Exclude: %s\n", strings.Join(profile.Exclude, ", ")) } fmt.Fprintln(out, "Packages:") for _, pkg := range profile.Packages { fmt.Fprintf(out, " %s\n", pkg) } + + width := 0 + for _, p := range result.Resolved { + width = max(width, len(p.Ref)) + } + fmt.Fprintln(out, "Resolved:") + for _, p := range result.Resolved { + fmt.Fprintf(out, " %-*s (%s)\n", width, p.Ref, p.Source) + } + if len(result.UnmatchedExcludes) > 0 { + fmt.Fprintln(out, "Unmatched excludes (no parent provides these):") + for _, ref := range result.UnmatchedExcludes { + fmt.Fprintf(out, " %s\n", ref) + } + } return nil }, } @@ -174,44 +207,132 @@ func newProfileRemoveCmd(deps *Deps) *cobra.Command { return cmd } -func newProfileApplyCmd(deps *Deps) *cobra.Command { +func newProfileExtendCmd(deps *Deps) *cobra.Command { + return newProfileListEditCmd(deps, profileListEdit{ + use: "extend ...", + short: "Add parent profiles to a profile", + done: "Profile %s now extends %s\n", + complete: completeProfileNames(deps), + run: func(d *dotsctl.Dots, cmd *cobra.Command, name string, items []string) error { + return d.ProfileExtend(cmd.Context(), name, items) + }, + }) +} + +func newProfileUnextendCmd(deps *Deps) *cobra.Command { + return newProfileListEditCmd(deps, profileListEdit{ + use: "unextend ...", + short: "Remove parent profiles from a profile", + done: "Profile %s no longer extends %s\n", + complete: completeProfileNames(deps), + run: func(d *dotsctl.Dots, cmd *cobra.Command, name string, items []string) error { + return d.ProfileUnextend(cmd.Context(), name, items) + }, + }) +} + +func newProfileExcludeCmd(deps *Deps) *cobra.Command { + return newProfileListEditCmd(deps, profileListEdit{ + use: "exclude /...", + short: "Exclude inherited packages from a profile", + done: "Profile %s now excludes %s\n", + complete: completeAvailablePackages(deps), + run: func(d *dotsctl.Dots, cmd *cobra.Command, name string, items []string) error { + return d.ProfileExclude(cmd.Context(), name, items) + }, + }) +} + +func newProfileUnexcludeCmd(deps *Deps) *cobra.Command { + return newProfileListEditCmd(deps, profileListEdit{ + use: "unexclude /...", + short: "Stop excluding packages from a profile", + done: "Profile %s no longer excludes %s\n", + complete: completeAvailablePackages(deps), + run: func(d *dotsctl.Dots, cmd *cobra.Command, name string, items []string) error { + return d.ProfileUnexclude(cmd.Context(), name, items) + }, + }) +} + +// profileListEdit describes a subcommand that edits one of a profile's lists +// (extends or exclude) with " ..." arguments. +type profileListEdit struct { + use string + short string + done string + complete cobra.CompletionFunc + run func(d *dotsctl.Dots, cmd *cobra.Command, name string, items []string) error +} + +func newProfileListEditCmd(deps *Deps, e profileListEdit) *cobra.Command { cmd := &cobra.Command{ - Use: "apply ", - Short: "Install all packages in a profile", - Args: cobra.ExactArgs(1), + Use: e.use, + Short: e.short, + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { d, err := newDotsService(deps) if err != nil { return err } - if err := d.ProfileApply(cmd.Context(), args[0]); err != nil { + if err := e.run(d, cmd, args[0], args[1:]); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Applied profile %s\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), e.done, args[0], strings.Join(args[1:], ", ")) return nil }, } - cmd.ValidArgsFunction = completeProfileNames(deps) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completeProfileNames(deps)(cmd, args, toComplete) + } + return e.complete(cmd, nil, toComplete) + } return cmd } -func newProfileSwitchCmd(deps *Deps) *cobra.Command { +func newProfileApplyCmd(deps *Deps) *cobra.Command { + var dryRun bool cmd := &cobra.Command{ - Use: "switch ", - Short: "Switch to a different profile", - Args: cobra.ExactArgs(1), + Use: "apply ", + Short: "Make installed packages match a profile", + Long: `Make installed packages match a profile exactly. + +Uninstalls every installed package that is not in the profile's resolved list +(plus core.always_installed), including packages installed by hand, installs +the missing ones, and records the profile as active.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { d, err := newDotsService(deps) if err != nil { return err } - if err := d.ProfileSwitch(cmd.Context(), args[0]); err != nil { + result, err := d.ProfileApply(cmd.Context(), dotsctl.ProfileApplyOptions{ + Name: args[0], + DryRun: dryRun, + }) + if err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Switched to profile %s\n", args[0]) + + out := cmd.OutOrStdout() + if result.DryRun { + fmt.Fprintln(out, "Dry run — no changes made:") + } + for _, ref := range result.Remove { + fmt.Fprintf(out, " remove %s\n", ref) + } + for _, ref := range result.Install { + fmt.Fprintf(out, " install %s\n", ref) + } + if !result.DryRun { + fmt.Fprintf(out, "Applied profile %s (%d removed, %d installed, %d unchanged)\n", + result.Profile, len(result.Remove), len(result.Install), len(result.Keep)) + } return nil }, } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be removed and installed without changing anything") cmd.ValidArgsFunction = completeProfileNames(deps) return cmd } diff --git a/pkg/cli/profile_test.go b/pkg/cli/profile_test.go new file mode 100644 index 0000000..98e7122 --- /dev/null +++ b/pkg/cli/profile_test.go @@ -0,0 +1,69 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProfileApply_DryRun(t *testing.T) { + sb := NewSandbox(t) + _, stderr, code := RunCommand(t, sb, "profile", "create", "empty") + require.Equal(t, 0, code, stderr) + + stdout, stderr, code := RunCommand(t, sb, "profile", "apply", "empty", "--dry-run") + require.Equal(t, 0, code, stderr) + require.Contains(t, stdout, "Dry run — no changes made:") + require.NotContains(t, stdout, "Applied profile") +} + +func TestProfileApply_MissingProfile(t *testing.T) { + sb := NewSandbox(t) + _, stderr, code := RunCommand(t, sb, "profile", "apply", "nope", "--dry-run") + require.NotEqual(t, 0, code) + require.Contains(t, stderr, `profile "nope"`) +} + +func TestProfileSwitch_Removed(t *testing.T) { + sb := NewSandbox(t) + stdout, _, _ := RunCommand(t, sb, "profile", "--help") + require.NotContains(t, stdout, "switch") + require.Contains(t, stdout, "apply") +} + +func TestProfileShow_ResolvedWithSources(t *testing.T) { + sb := NewSandbox(t) + for _, args := range [][]string{ + {"profile", "create", "base"}, + {"profile", "add", "base", "personal/git", "personal/tmux"}, + {"profile", "create", "work", "--extends", "base"}, + {"profile", "add", "work", "personal/nvim"}, + {"profile", "exclude", "work", "personal/tmux", "personal/none"}, + } { + _, stderr, code := RunCommand(t, sb, args...) + require.Equal(t, 0, code, "%v: %s", args, stderr) + } + + stdout, stderr, code := RunCommand(t, sb, "profile", "show", "work") + require.Equal(t, 0, code, stderr) + require.Contains(t, stdout, "Extends: base\n") + require.Contains(t, stdout, "Exclude: personal/tmux, personal/none\n") + require.Contains(t, stdout, "Resolved:\n personal/git (base)\n personal/nvim (work)\n") + require.NotContains(t, stdout, "personal/tmux (") + require.Contains(t, stdout, "Unmatched excludes (no parent provides these):\n personal/none\n") +} + +func TestProfileExtend_CycleFails(t *testing.T) { + sb := NewSandbox(t) + for _, args := range [][]string{ + {"profile", "create", "base"}, + {"profile", "create", "work", "--extends", "base"}, + } { + _, stderr, code := RunCommand(t, sb, args...) + require.Equal(t, 0, code, "%v: %s", args, stderr) + } + + _, stderr, code := RunCommand(t, sb, "profile", "extend", "base", "work") + require.NotEqual(t, 0, code) + require.Contains(t, stderr, "base → work → base") +} diff --git a/pkg/dots/config.go b/pkg/dots/config.go index e365655..4647510 100644 --- a/pkg/dots/config.go +++ b/pkg/dots/config.go @@ -30,10 +30,13 @@ type Config struct { // CoreConfig holds top-level behavior settings. type CoreConfig struct { - ActiveProfile string `yaml:"active_profile,omitempty"` ConflictStrategy string `yaml:"conflict_strategy,omitempty"` Backup *bool `yaml:"backup,omitempty"` LinkStrategy LinkStrategy `yaml:"link_strategy,omitempty"` + // AlwaysInstalled lists packages (tap/package) included in every applied + // profile. They override profile excludes. Platform overrides append to + // the base list rather than replacing it. + AlwaysInstalled []string `yaml:"always_installed,omitempty"` } // GitConfig holds git-related settings. @@ -92,9 +95,6 @@ func LoadConfigFile(path string) (*Config, error) { func MergeConfig(base, override *Config) *Config { result := *base - if override.Core.ActiveProfile != "" { - result.Core.ActiveProfile = override.Core.ActiveProfile - } if override.Core.ConflictStrategy != "" { result.Core.ConflictStrategy = override.Core.ConflictStrategy } @@ -104,6 +104,7 @@ func MergeConfig(base, override *Config) *Config { if override.Core.LinkStrategy != "" { result.Core.LinkStrategy = override.Core.LinkStrategy } + result.Core.AlwaysInstalled = appendUnique(result.Core.AlwaysInstalled, override.Core.AlwaysInstalled...) if override.Git.DefaultBranch != "" { result.Git.DefaultBranch = override.Git.DefaultBranch } @@ -162,6 +163,7 @@ func (c *Config) ResolveCorePlatform(p Platform) CoreConfig { if osOverride.Backup != nil { core.Backup = osOverride.Backup } + core.AlwaysInstalled = appendUnique(core.AlwaysInstalled, osOverride.AlwaysInstalled...) } if archOverride, ok := c.Platform[p.String()]; ok { @@ -174,7 +176,29 @@ func (c *Config) ResolveCorePlatform(p Platform) CoreConfig { if archOverride.Backup != nil { core.Backup = archOverride.Backup } + core.AlwaysInstalled = appendUnique(core.AlwaysInstalled, archOverride.AlwaysInstalled...) } return core } + +// appendUnique returns a new slice holding base followed by each item of +// extra not already present, preserving first-occurrence order. It never +// aliases base, so callers can safely append to the result. +func appendUnique(base []string, extra ...string) []string { + if len(base) == 0 && len(extra) == 0 { + return nil + } + out := make([]string, 0, len(base)+len(extra)) + seen := make(map[string]struct{}, len(base)+len(extra)) + for _, list := range [][]string{base, extra} { + for _, s := range list { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + } + return out +} diff --git a/pkg/dots/config_test.go b/pkg/dots/config_test.go index 61f778c..5852a38 100644 --- a/pkg/dots/config_test.go +++ b/pkg/dots/config_test.go @@ -23,8 +23,9 @@ core: func TestParseConfig_Full(t *testing.T) { data := []byte(` core: - active_profile: work + active_profile: work # legacy key; ignored now that the lockfile tracks it conflict_strategy: overlay + always_installed: [personal/dots] backup: false link_strategy: copy @@ -56,7 +57,7 @@ platform: `) cfg, err := dots.ParseConfig(data) require.NoError(t, err) - require.Equal(t, "work", cfg.Core.ActiveProfile) + require.Equal(t, []string{"personal/dots"}, cfg.Core.AlwaysInstalled) require.Equal(t, "overlay", cfg.Core.ConflictStrategy) require.NotNil(t, cfg.Core.Backup) require.False(t, *cfg.Core.Backup) @@ -79,10 +80,10 @@ func TestParseConfig_Invalid(t *testing.T) { func TestMergeConfig(t *testing.T) { base := dots.DefaultConfig() - base.Core.ActiveProfile = "personal" + base.Core.AlwaysInstalled = []string{"personal/dots"} override := &dots.Config{ - Core: dots.CoreConfig{ActiveProfile: "work"}, + Core: dots.CoreConfig{AlwaysInstalled: []string{"personal/zsh", "personal/dots"}}, Taps: map[string]dots.TapConfig{ "work": {URL: "git@github.com:corp/dotfiles.git"}, }, @@ -90,7 +91,7 @@ func TestMergeConfig(t *testing.T) { } result := dots.MergeConfig(&base, override) - require.Equal(t, "work", result.Core.ActiveProfile) + require.Equal(t, []string{"personal/dots", "personal/zsh"}, result.Core.AlwaysInstalled) require.Equal(t, dots.LinkCopy, result.Core.LinkStrategy) // from base default require.Equal(t, "main", result.Git.DefaultBranch) // from base default require.Equal(t, "git@github.com:corp/dotfiles.git", result.Taps["work"].URL) @@ -119,3 +120,21 @@ func TestConfig_ResolveCorePlatform(t *testing.T) { require.Equal(t, dots.LinkHardlink, core.LinkStrategy) }) } + +func TestConfig_ResolveCorePlatform_AlwaysInstalledConcatenates(t *testing.T) { + cfg := dots.DefaultConfig() + cfg.Core.AlwaysInstalled = []string{"personal/dots"} + cfg.Platform = map[string]dots.CoreConfig{ + "darwin": {AlwaysInstalled: []string{"personal/brew", "personal/dots"}}, + "darwin-arm64": {AlwaysInstalled: []string{"personal/rosetta"}}, + } + + core := cfg.ResolveCorePlatform(dots.Platform{OS: "darwin", Arch: "arm64"}) + require.Equal(t, []string{"personal/dots", "personal/brew", "personal/rosetta"}, core.AlwaysInstalled) + + core = cfg.ResolveCorePlatform(dots.Platform{OS: "linux", Arch: "amd64"}) + require.Equal(t, []string{"personal/dots"}, core.AlwaysInstalled) + + // Resolving must not mutate the base list. + require.Equal(t, []string{"personal/dots"}, cfg.Core.AlwaysInstalled) +} diff --git a/pkg/dots/errors.go b/pkg/dots/errors.go index 212730b..a111e22 100644 --- a/pkg/dots/errors.go +++ b/pkg/dots/errors.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "strings" ) // Sentinel errors for simple equality checks. @@ -46,6 +47,20 @@ func (e *PackageNotFoundError) Is(target error) bool { return target == ErrNotExist } +// ProfileCycleError is returned when a profile's extends chain loops back on +// itself. Chain lists the profiles in order, ending with the repeated name. +type ProfileCycleError struct { + Chain []string +} + +func (e *ProfileCycleError) Error() string { + return fmt.Sprintf("profile extends cycle: %s", strings.Join(e.Chain, " → ")) +} + +func (e *ProfileCycleError) Is(target error) bool { + return target == ErrConflict +} + // InvalidConfigError represents a validation failure for dots config. type InvalidConfigError struct { Msg string diff --git a/pkg/dots/profile.go b/pkg/dots/profile.go index 14b78bf..9913c65 100644 --- a/pkg/dots/profile.go +++ b/pkg/dots/profile.go @@ -13,9 +13,49 @@ const ( // Profile represents a named collection of packages. type Profile struct { - Name string `yaml:"name"` - Extends string `yaml:"extends,omitempty"` - Packages []string `yaml:"packages"` + Name string `yaml:"name"` + // Extends lists parent profiles, resolved in order. In YAML it may be a + // single name or a list. + Extends StringList `yaml:"extends,omitempty"` + Packages []string `yaml:"packages"` + // Exclude drops packages inherited through Extends. It never affects + // this profile's own Packages. + Exclude []string `yaml:"exclude,omitempty"` +} + +// StringList is a list of strings that unmarshals from either a YAML scalar +// or a sequence, and marshals a single element back to a scalar so +// single-value files stay unchanged. +type StringList []string + +// UnmarshalYAML implements yaml.Unmarshaler. +func (l *StringList) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + if node.Tag == "!!null" || node.Value == "" { + *l = nil + return nil + } + *l = StringList{node.Value} + return nil + case yaml.SequenceNode: + var items []string + if err := node.Decode(&items); err != nil { + return err + } + *l = items + return nil + default: + return fmt.Errorf("line %d: expected a string or a list of strings", node.Line) + } +} + +// MarshalYAML implements yaml.Marshaler. +func (l StringList) MarshalYAML() (any, error) { + if len(l) == 1 { + return l[0], nil + } + return []string(l), nil } // ParseProfile parses a Profile from YAML bytes. diff --git a/pkg/dots/profile_test.go b/pkg/dots/profile_test.go new file mode 100644 index 0000000..e55725a --- /dev/null +++ b/pkg/dots/profile_test.go @@ -0,0 +1,60 @@ +package dots_test + +import ( + "errors" + "testing" + + "github.com/jlrickert/dots/pkg/dots" + "github.com/stretchr/testify/require" +) + +func TestParseProfile_ExtendsScalar(t *testing.T) { + p, err := dots.ParseProfile([]byte("name: laptop\nextends: base\n")) + require.NoError(t, err) + require.Equal(t, dots.StringList{"base"}, p.Extends) +} + +func TestParseProfile_ExtendsList(t *testing.T) { + p, err := dots.ParseProfile([]byte("name: work\nextends: [base, dev]\npackages: [work/zellij]\nexclude: [personal/tmux]\n")) + require.NoError(t, err) + require.Equal(t, dots.StringList{"base", "dev"}, p.Extends) + require.Equal(t, []string{"work/zellij"}, p.Packages) + require.Equal(t, []string{"personal/tmux"}, p.Exclude) +} + +func TestParseProfile_ExtendsInvalid(t *testing.T) { + _, err := dots.ParseProfile([]byte("name: bad\nextends: {a: b}\n")) + require.ErrorIs(t, err, dots.ErrParse) +} + +func TestMarshalProfile_ExtendsRoundTrip(t *testing.T) { + cases := map[string]struct { + extends dots.StringList + want string + }{ + "single stays scalar": {dots.StringList{"base"}, "extends: base\n"}, + "multiple is a list": {dots.StringList{"base", "dev"}, "extends:\n - base\n - dev\n"}, + "empty is omitted": {nil, ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + data, err := dots.MarshalProfile(&dots.Profile{Name: "p", Extends: tc.extends, Packages: []string{}}) + require.NoError(t, err) + if tc.want == "" { + require.NotContains(t, string(data), "extends") + } else { + require.Contains(t, string(data), tc.want) + } + + back, err := dots.ParseProfile(data) + require.NoError(t, err) + require.Equal(t, tc.extends, back.Extends) + }) + } +} + +func TestProfileCycleError(t *testing.T) { + err := &dots.ProfileCycleError{Chain: []string{"work", "dev", "work"}} + require.True(t, errors.Is(err, dots.ErrConflict)) + require.Equal(t, "profile extends cycle: work → dev → work", err.Error()) +} diff --git a/pkg/dotsctl/dots_init.go b/pkg/dotsctl/dots_init.go index ee83af9..232efef 100644 --- a/pkg/dotsctl/dots_init.go +++ b/pkg/dotsctl/dots_init.go @@ -18,6 +18,9 @@ type InitOptions struct { // Set this to match the tap name declared in the bootstrap package's config // so the on-disk tap and the installed config.yaml agree. Name string + // Apply is an optional profile to apply once initialization (including + // any bootstrap install) has finished. + Apply string } // defaultTapName is the tap name used when --name is not supplied. @@ -26,7 +29,8 @@ const defaultTapName = "default" // Init initializes the dots environment: creates config and state directories, // writes a default config, and optionally registers an initial tap. // When --from and --path are provided, it clones the tap, finds the dots -// package at the given path, and installs it (self-bootstrapping). +// package at the given path, and installs it (self-bootstrapping). When +// Apply is set, the named profile is applied last. func (d *Dots) Init(ctx context.Context, opts InitOptions) error { // Create directories. configDir := d.PathService.ConfigDir() @@ -99,5 +103,14 @@ func (d *Dots) Init(ctx context.Context, opts InitOptions) error { } } + if opts.Apply != "" { + // The bootstrap package may have just linked a new config.yaml + // (with core.always_installed), so drop any cached copy. + d.ConfigService.InvalidateCache() + if _, err := d.ProfileApply(ctx, ProfileApplyOptions{Name: opts.Apply}); err != nil { + return fmt.Errorf("apply profile %s (init completed): %w", opts.Apply, err) + } + } + return nil } diff --git a/pkg/dotsctl/dots_profile.go b/pkg/dotsctl/dots_profile.go index 018ff42..eced373 100644 --- a/pkg/dotsctl/dots_profile.go +++ b/pkg/dotsctl/dots_profile.go @@ -3,50 +3,57 @@ package dotsctl import ( "context" "fmt" - "os" "path/filepath" + "slices" "github.com/jlrickert/dots/pkg/dots" ) -// ProfileCreate creates a new empty profile. -func (d *Dots) ProfileCreate(ctx context.Context, name string) error { - path := d.profilePath(name) - if _, err := os.Stat(path); err == nil { +// ProfileCreateOptions configures profile creation. +type ProfileCreateOptions struct { + // Name is the new profile's name. + Name string + // Extends lists parent profiles. Each must exist and must not create a + // cycle. + Extends []string +} + +// ProfileCreate creates a new profile with no packages of its own. +func (d *Dots) ProfileCreate(ctx context.Context, opts ProfileCreateOptions) error { + path := d.profilePath(opts.Name) + if _, err := d.Runtime.Stat(path, false); err == nil { return dots.ErrExist } profile := &dots.Profile{ - Name: name, + Name: opts.Name, + Extends: appendMissing(dots.StringList(nil), opts.Extends), Packages: []string{}, } - - data, err := dots.MarshalProfile(profile) - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if _, _, err := d.resolveLoadedProfile(profile, nil); err != nil { return err } - return os.WriteFile(path, data, 0o644) + return d.saveProfile(profile) } // ProfileDelete removes a profile definition. func (d *Dots) ProfileDelete(ctx context.Context, name string) error { path := d.profilePath(name) - if _, err := os.Stat(path); os.IsNotExist(err) { - return dots.ErrNotExist + if _, err := d.Runtime.Stat(path, false); err != nil { + if isNotExist(err) { + return dots.ErrNotExist + } + return err } - return os.Remove(path) + return d.Runtime.Remove(path, false) } // ProfileList returns all available profile names. func (d *Dots) ProfileList(ctx context.Context) ([]string, error) { dir := d.PathService.ProfilesDir() - entries, err := os.ReadDir(dir) + entries, err := d.Runtime.ReadDir(dir) if err != nil { - if os.IsNotExist(err) { + if isNotExist(err) { return nil, nil } return nil, err @@ -65,9 +72,35 @@ func (d *Dots) ProfileList(ctx context.Context) ([]string, error) { return names, nil } -// ProfileShow returns the profile definition. -func (d *Dots) ProfileShow(ctx context.Context, name string) (*dots.Profile, error) { - return d.loadProfile(name) +// ProfileShowResult holds a profile definition and its resolution. +type ProfileShowResult struct { + Profile *dots.Profile + // Resolved is the list apply would install, including + // core.always_installed, with the source of each package. + Resolved []ResolvedPackage + // UnmatchedExcludes lists excludes that match no inherited package. + UnmatchedExcludes []string +} + +// ProfileShow returns the profile definition and its resolved package list. +func (d *Dots) ProfileShow(ctx context.Context, name string) (*ProfileShowResult, error) { + profile, err := d.loadProfile(name) + if err != nil { + return nil, fmt.Errorf("profile %q: %w", name, err) + } + pkgs, unmatched, err := d.resolveLoadedProfile(profile, nil) + if err != nil { + return nil, err + } + resolved, err := d.withAlwaysInstalled(pkgs) + if err != nil { + return nil, err + } + return &ProfileShowResult{ + Profile: profile, + Resolved: resolved, + UnmatchedExcludes: unmatched, + }, nil } // ProfileAdd adds packages to a profile. @@ -109,43 +142,66 @@ func (d *Dots) ProfileRemove(ctx context.Context, name, pkg string) error { return d.saveProfile(profile) } -// ProfileApply installs all packages in a profile. -func (d *Dots) ProfileApply(ctx context.Context, name string) error { - profile, err := d.resolveProfileChain(name) - if err != nil { - return err - } +// ProfileExtend adds parent profiles to a profile's extends list. The change +// is rejected if a parent is missing or it would create a cycle. +func (d *Dots) ProfileExtend(ctx context.Context, name string, parents []string) error { + return d.updateProfile(name, true, func(p *dots.Profile) { + p.Extends = appendMissing(p.Extends, parents) + }) +} - for _, pkg := range profile.Packages { - _, err := d.Install(ctx, InstallOptions{Package: pkg}) - if err != nil { - return fmt.Errorf("install %s: %w", pkg, err) - } - } +// ProfileUnextend removes parent profiles from a profile's extends list. +func (d *Dots) ProfileUnextend(ctx context.Context, name string, parents []string) error { + return d.updateProfile(name, false, func(p *dots.Profile) { + p.Extends = removeAll(p.Extends, parents) + }) +} - // Update active profile in lockfile - lockfile, err := d.Repo.ReadLockfile(ctx) - if err != nil && !isNotExist(err) { - return err +// ProfileExclude adds packages to a profile's exclude list. +func (d *Dots) ProfileExclude(ctx context.Context, name string, packages []string) error { + return d.updateProfile(name, false, func(p *dots.Profile) { + p.Exclude = appendMissing(p.Exclude, packages) + }) +} + +// ProfileUnexclude removes packages from a profile's exclude list. +func (d *Dots) ProfileUnexclude(ctx context.Context, name string, packages []string) error { + return d.updateProfile(name, false, func(p *dots.Profile) { + p.Exclude = removeAll(p.Exclude, packages) + }) +} + +// updateProfile loads a profile, applies edit, optionally validates that the +// result still resolves, and saves it. +func (d *Dots) updateProfile(name string, validate bool, edit func(*dots.Profile)) error { + profile, err := d.loadProfile(name) + if err != nil { + return fmt.Errorf("profile %q: %w", name, err) } - if lockfile == nil { - lockfile = &dots.Lockfile{} + edit(profile) + if validate { + if _, _, err := d.resolveLoadedProfile(profile, nil); err != nil { + return err + } } - lockfile.State.ActiveProfile = name - return d.Repo.WriteLockfile(ctx, lockfile) + return d.saveProfile(profile) } -// ProfileSwitch removes current profile packages and applies the new one. -func (d *Dots) ProfileSwitch(ctx context.Context, name string) error { - // Remove currently installed packages - lockfile, err := d.Repo.ReadLockfile(ctx) - if err == nil { - for _, pkg := range lockfile.Installed { - _ = d.Remove(ctx, RemoveOptions{Package: pkg.Package}) +// appendMissing appends each item not already in list, preserving order. +func appendMissing[S ~[]string](list S, items []string) S { + for _, item := range items { + if !slices.Contains(list, item) { + list = append(list, item) } } + return list +} - return d.ProfileApply(ctx, name) +// removeAll returns list without any of items. +func removeAll[S ~[]string](list S, items []string) S { + return slices.DeleteFunc(list, func(s string) bool { + return slices.Contains(items, s) + }) } // ProfileExport returns the profile as YAML bytes. @@ -171,9 +227,9 @@ func (d *Dots) profilePath(name string) string { } func (d *Dots) loadProfile(name string) (*dots.Profile, error) { - data, err := os.ReadFile(d.profilePath(name)) + data, err := d.Runtime.ReadFile(d.profilePath(name)) if err != nil { - if os.IsNotExist(err) { + if isNotExist(err) { return nil, dots.ErrNotExist } return nil, err @@ -187,44 +243,8 @@ func (d *Dots) saveProfile(profile *dots.Profile) error { return err } path := d.profilePath(profile.Name) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := d.Runtime.Mkdir(filepath.Dir(path), 0o755, true); err != nil { return err } - return os.WriteFile(path, data, 0o644) -} - -// resolveProfileChain resolves a profile and its extends chain. -func (d *Dots) resolveProfileChain(name string) (*dots.Profile, error) { - profile, err := d.loadProfile(name) - if err != nil { - return nil, err - } - - if profile.Extends == "" { - return profile, nil - } - - parent, err := d.resolveProfileChain(profile.Extends) - if err != nil { - return nil, fmt.Errorf("resolve parent profile %s: %w", profile.Extends, err) - } - - // Merge: parent packages first, then child's (dedup) - seen := make(map[string]struct{}) - var merged []string - for _, p := range parent.Packages { - if _, ok := seen[p]; !ok { - merged = append(merged, p) - seen[p] = struct{}{} - } - } - for _, p := range profile.Packages { - if _, ok := seen[p]; !ok { - merged = append(merged, p) - seen[p] = struct{}{} - } - } - - profile.Packages = merged - return profile, nil + return d.Runtime.WriteFile(path, data, 0o644) } diff --git a/pkg/dotsctl/dots_profile_apply.go b/pkg/dotsctl/dots_profile_apply.go new file mode 100644 index 0000000..a2a57eb --- /dev/null +++ b/pkg/dotsctl/dots_profile_apply.go @@ -0,0 +1,105 @@ +package dotsctl + +import ( + "context" + "fmt" + + "github.com/jlrickert/dots/pkg/dots" +) + +// ProfileApplyOptions configures the profile apply operation. +type ProfileApplyOptions struct { + // Name is the profile to apply. + Name string + // DryRun computes the plan without removing or installing anything. + DryRun bool +} + +// ProfileApplyResult describes what apply removed, installed, and kept. +type ProfileApplyResult struct { + Profile string + // Remove lists installed packages not in the resolved profile. + Remove []string + // Install lists resolved packages that were not installed. + Install []string + // Keep lists packages that were installed and remain in the profile. + Keep []string + DryRun bool +} + +// ProfileApply makes the installed packages match a profile exactly: it +// uninstalls every installed package not in the profile's resolved list +// (plus core.always_installed), installs the missing ones, and records the +// profile as active in the lockfile. Packages already installed and still in +// the list are left untouched. +func (d *Dots) ProfileApply(ctx context.Context, opts ProfileApplyOptions) (*ProfileApplyResult, error) { + resolved, err := d.resolveProfileForApply(opts.Name) + if err != nil { + return nil, err + } + + lockfile, err := d.Repo.ReadLockfile(ctx) + if err != nil && !isNotExist(err) { + return nil, fmt.Errorf("read lockfile: %w", err) + } + + installed := make(map[string]struct{}) + if lockfile != nil { + for _, p := range lockfile.Installed { + installed[p.Package] = struct{}{} + } + } + wanted := make(map[string]struct{}, len(resolved)) + for _, p := range resolved { + wanted[p.Ref] = struct{}{} + } + + result := &ProfileApplyResult{Profile: opts.Name, DryRun: opts.DryRun} + if lockfile != nil { + for _, p := range lockfile.Installed { + if _, ok := wanted[p.Package]; !ok { + result.Remove = append(result.Remove, p.Package) + } + } + } + for _, p := range resolved { + if _, ok := installed[p.Ref]; ok { + result.Keep = append(result.Keep, p.Ref) + } else { + result.Install = append(result.Install, p.Ref) + } + } + + if opts.DryRun { + return result, nil + } + + // Remove first so packages that conflict with the new profile are gone + // before anything is installed in their place. + for _, ref := range result.Remove { + if err := d.Remove(ctx, RemoveOptions{Package: ref}); err != nil { + return result, fmt.Errorf("remove %s: %w", ref, err) + } + } + for _, ref := range result.Install { + if _, err := d.Install(ctx, InstallOptions{Package: ref}); err != nil { + return result, fmt.Errorf("install %s: %w", ref, err) + } + } + + // Remove and Install rewrite the lockfile, so re-read before recording + // the active profile. + lockfile, err = d.Repo.ReadLockfile(ctx) + if err != nil && !isNotExist(err) { + return result, fmt.Errorf("read lockfile: %w", err) + } + if lockfile == nil { + lockfile = &dots.Lockfile{} + } + lockfile.State.ActiveProfile = opts.Name + lockfile.State.LastApplied = d.Runtime.Clock().Now() + if err := d.Repo.WriteLockfile(ctx, lockfile); err != nil { + return result, fmt.Errorf("write lockfile: %w", err) + } + return result, nil +} diff --git a/pkg/dotsctl/dots_profile_resolve.go b/pkg/dotsctl/dots_profile_resolve.go new file mode 100644 index 0000000..1bff9db --- /dev/null +++ b/pkg/dotsctl/dots_profile_resolve.go @@ -0,0 +1,125 @@ +package dotsctl + +import ( + "fmt" + "slices" + + "github.com/jlrickert/dots/pkg/dots" +) + +// AlwaysInstalledSource is the ResolvedPackage.Source for packages that come +// from core.always_installed rather than a profile. +const AlwaysInstalledSource = "always_installed" + +// ResolvedPackage is one entry in a profile's resolved package list. +type ResolvedPackage struct { + // Ref is the "/" identifier. + Ref string + // Source is the profile that listed the package, or + // AlwaysInstalledSource. + Source string +} + +// resolveProfile returns the resolved package list for a profile, following +// docs/profiles.md#resolution: parents in extends order (each already +// resolved with its own excludes), concatenated with first-occurrence dedupe, +// then this profile's excludes, then its own packages. stack holds the +// profiles currently being resolved and is used for cycle detection. +func (d *Dots) resolveProfile(name string, stack []string) ([]ResolvedPackage, error) { + if i := slices.Index(stack, name); i >= 0 { + chain := append(slices.Clone(stack[i:]), name) + return nil, &dots.ProfileCycleError{Chain: chain} + } + + profile, err := d.loadProfile(name) + if err != nil { + if len(stack) > 0 { + return nil, fmt.Errorf("profile %q (extended by %q): %w", name, stack[len(stack)-1], err) + } + return nil, fmt.Errorf("profile %q: %w", name, err) + } + + resolved, _, err := d.resolveLoadedProfile(profile, stack) + return resolved, err +} + +// resolveLoadedProfile resolves an already-loaded profile, which may not be +// saved yet. It also returns the excludes that matched no inherited package. +func (d *Dots) resolveLoadedProfile(profile *dots.Profile, stack []string) ([]ResolvedPackage, []string, error) { + stack = append(slices.Clone(stack), profile.Name) + + var inherited []ResolvedPackage + seen := make(map[string]struct{}) + for _, parent := range profile.Extends { + pkgs, err := d.resolveProfile(parent, stack) + if err != nil { + return nil, nil, err + } + for _, p := range pkgs { + if _, ok := seen[p.Ref]; ok { + continue + } + seen[p.Ref] = struct{}{} + inherited = append(inherited, p) + } + } + + var unmatched []string + for _, ref := range profile.Exclude { + if _, ok := seen[ref]; !ok { + unmatched = append(unmatched, ref) + } + } + + resolved := make([]ResolvedPackage, 0, len(inherited)+len(profile.Packages)) + seen = make(map[string]struct{}) + for _, p := range inherited { + if slices.Contains(profile.Exclude, p.Ref) { + continue + } + seen[p.Ref] = struct{}{} + resolved = append(resolved, p) + } + for _, ref := range profile.Packages { + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + resolved = append(resolved, ResolvedPackage{Ref: ref, Source: profile.Name}) + } + return resolved, unmatched, nil +} + +// resolveProfileForApply resolves a profile and prepends the platform-resolved +// core.always_installed packages, which no exclude can remove. +func (d *Dots) resolveProfileForApply(name string) ([]ResolvedPackage, error) { + pkgs, err := d.resolveProfile(name, nil) + if err != nil { + return nil, err + } + return d.withAlwaysInstalled(pkgs) +} + +// withAlwaysInstalled prepends core.always_installed to pkgs, deduped. +func (d *Dots) withAlwaysInstalled(pkgs []ResolvedPackage) ([]ResolvedPackage, error) { + cfg, err := d.ConfigService.Config(true) + if err != nil { + return nil, err + } + always := cfg.ResolveCorePlatform(d.PathService.Platform).AlwaysInstalled + + resolved := make([]ResolvedPackage, 0, len(always)+len(pkgs)) + seen := make(map[string]struct{}) + for _, ref := range always { + seen[ref] = struct{}{} + resolved = append(resolved, ResolvedPackage{Ref: ref, Source: AlwaysInstalledSource}) + } + for _, p := range pkgs { + if _, ok := seen[p.Ref]; ok { + continue + } + seen[p.Ref] = struct{}{} + resolved = append(resolved, p) + } + return resolved, nil +} diff --git a/pkg/dotsctl/dots_profile_test.go b/pkg/dotsctl/dots_profile_test.go new file mode 100644 index 0000000..af11b47 --- /dev/null +++ b/pkg/dotsctl/dots_profile_test.go @@ -0,0 +1,393 @@ +package dotsctl_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/jlrickert/dots/pkg/dots" + "github.com/jlrickert/dots/pkg/dotsctl" + "github.com/stretchr/testify/require" +) + +func writeProfile(t *testing.T, d *dotsctl.Dots, yaml string) { + t.Helper() + require.NoError(t, d.ProfileImport(context.Background(), []byte(yaml))) +} + +// writeConfig writes config.yaml with plain os calls because ConfigService +// reads it with os (see seedPackage for the same pattern). +func writeConfig(t *testing.T, d *dotsctl.Dots, yaml string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(d.ConfigService.ConfigPath), 0o755)) + require.NoError(t, os.WriteFile(d.ConfigService.ConfigPath, []byte(yaml), 0o644)) + d.ConfigService.InvalidateCache() +} + +func refs(pkgs []dotsctl.ResolvedPackage) []string { + out := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + out = append(out, p.Ref) + } + return out +} + +func installedRefs(t *testing.T, repo *dots.MemoryRepo) []string { + t.Helper() + lf, err := repo.ReadLockfile(context.Background()) + if err != nil { + return nil + } + var out []string + for _, p := range lf.Installed { + out = append(out, p.Package) + } + return out +} + +// seedDocsExample writes the profiles from the docs/profiles.md example. +func seedDocsExample(t *testing.T, d *dotsctl.Dots) { + writeProfile(t, d, "name: base\npackages: [personal/zsh, personal/git, personal/tmux]\n") + writeProfile(t, d, "name: dev\nextends: base\npackages: [personal/nvim, personal/go]\n") + writeProfile(t, d, "name: work\nextends: [base, dev]\npackages: [work/zellij]\nexclude: [personal/tmux]\n") +} + +// --- Resolution --- + +func TestResolveProfile_DocsExample(t *testing.T) { + d, _ := newTestDots(t) + seedDocsExample(t, d) + + got, err := d.ResolveProfile("work") + require.NoError(t, err) + require.Equal(t, []dotsctl.ResolvedPackage{ + {Ref: "personal/zsh", Source: "base"}, + {Ref: "personal/git", Source: "base"}, + {Ref: "personal/nvim", Source: "dev"}, + {Ref: "personal/go", Source: "dev"}, + {Ref: "work/zellij", Source: "work"}, + }, got) +} + +func TestResolveProfile_DiamondDedupes(t *testing.T) { + d, _ := newTestDots(t) + writeProfile(t, d, "name: base\npackages: [t/a]\n") + writeProfile(t, d, "name: left\nextends: base\npackages: [t/l]\n") + writeProfile(t, d, "name: right\nextends: base\npackages: [t/r, t/a]\n") + writeProfile(t, d, "name: top\nextends: [left, right]\n") + + got, err := d.ResolveProfile("top") + require.NoError(t, err) + require.Equal(t, []string{"t/a", "t/l", "t/r"}, refs(got)) +} + +func TestResolveProfile_Cycle(t *testing.T) { + d, _ := newTestDots(t) + writeProfile(t, d, "name: work\nextends: dev\n") + writeProfile(t, d, "name: dev\nextends: work\n") + + _, err := d.ResolveProfile("work") + require.ErrorIs(t, err, dots.ErrConflict) + var cycle *dots.ProfileCycleError + require.ErrorAs(t, err, &cycle) + require.Equal(t, []string{"work", "dev", "work"}, cycle.Chain) +} + +func TestResolveProfile_SelfCycle(t *testing.T) { + d, _ := newTestDots(t) + writeProfile(t, d, "name: loop\nextends: loop\n") + + _, err := d.ResolveProfile("loop") + var cycle *dots.ProfileCycleError + require.ErrorAs(t, err, &cycle) + require.Equal(t, []string{"loop", "loop"}, cycle.Chain) +} + +func TestResolveProfile_MissingParent(t *testing.T) { + d, _ := newTestDots(t) + writeProfile(t, d, "name: work\nextends: [base]\n") + + _, err := d.ResolveProfile("work") + require.ErrorIs(t, err, dots.ErrNotExist) + require.Contains(t, err.Error(), `"base"`) + require.Contains(t, err.Error(), `extended by "work"`) +} + +func TestResolveProfile_MissingProfile(t *testing.T) { + d, _ := newTestDots(t) + _, err := d.ResolveProfile("nope") + require.ErrorIs(t, err, dots.ErrNotExist) +} + +func TestResolveProfile_ExcludeOnlyHitsInherited(t *testing.T) { + d, _ := newTestDots(t) + writeProfile(t, d, "name: base\npackages: [t/a, t/b]\n") + writeProfile(t, d, "name: child\nextends: base\npackages: [t/b]\nexclude: [t/a, t/b]\n") + + got, err := d.ResolveProfile("child") + require.NoError(t, err) + require.Equal(t, []dotsctl.ResolvedPackage{{Ref: "t/b", Source: "child"}}, got) +} + +func TestResolveProfile_ExcludesAreInherited(t *testing.T) { + d, _ := newTestDots(t) + writeProfile(t, d, "name: base\npackages: [t/a, t/b]\n") + writeProfile(t, d, "name: mid\nextends: base\nexclude: [t/a]\n") + writeProfile(t, d, "name: leaf\nextends: mid\n") + + got, err := d.ResolveProfile("leaf") + require.NoError(t, err) + require.Equal(t, []string{"t/b"}, refs(got)) +} + +// --- Apply --- + +func TestProfileApply_Reconciles(t *testing.T) { + d, repo := newTestDots(t) + ctx := context.Background() + for _, pkg := range []string{"keep", "extra", "manual", "missing"} { + seedPackage(t, d, repo, "t", pkg) + } + for _, pkg := range []string{"keep", "extra", "manual"} { + _, err := d.Install(ctx, dotsctl.InstallOptions{Package: "t/" + pkg}) + require.NoError(t, err) + } + writeProfile(t, d, "name: p\npackages: [t/keep, t/missing]\n") + + before, err := repo.ReadLockfile(ctx) + require.NoError(t, err) + var keepCommit dots.InstalledPackage + for _, p := range before.Installed { + if p.Package == "t/keep" { + keepCommit = p + } + } + + result, err := d.ProfileApply(ctx, dotsctl.ProfileApplyOptions{Name: "p"}) + require.NoError(t, err) + require.Equal(t, []string{"t/extra", "t/manual"}, result.Remove) + require.Equal(t, []string{"t/missing"}, result.Install) + require.Equal(t, []string{"t/keep"}, result.Keep) + + require.ElementsMatch(t, []string{"t/keep", "t/missing"}, installedRefs(t, repo)) + + lf, err := repo.ReadLockfile(ctx) + require.NoError(t, err) + require.Equal(t, "p", lf.State.ActiveProfile) + for _, p := range lf.Installed { + if p.Package == "t/keep" { + require.Equal(t, keepCommit, p, "kept package must not be reinstalled") + } + } +} + +func TestProfileApply_DryRunChangesNothing(t *testing.T) { + d, repo := newTestDots(t) + ctx := context.Background() + seedPackage(t, d, repo, "t", "old") + seedPackage(t, d, repo, "t", "new") + _, err := d.Install(ctx, dotsctl.InstallOptions{Package: "t/old"}) + require.NoError(t, err) + writeProfile(t, d, "name: p\npackages: [t/new]\n") + + result, err := d.ProfileApply(ctx, dotsctl.ProfileApplyOptions{Name: "p", DryRun: true}) + require.NoError(t, err) + require.True(t, result.DryRun) + require.Equal(t, []string{"t/old"}, result.Remove) + require.Equal(t, []string{"t/new"}, result.Install) + + require.Equal(t, []string{"t/old"}, installedRefs(t, repo)) + lf, err := repo.ReadLockfile(ctx) + require.NoError(t, err) + require.Empty(t, lf.State.ActiveProfile) +} + +func TestProfileApply_AlwaysInstalledKeepsBootstrapPackage(t *testing.T) { + d, repo := newTestDots(t) + ctx := context.Background() + seedPackage(t, d, repo, "personal", "dots") + seedPackage(t, d, repo, "personal", "zsh") + _, err := d.Install(ctx, dotsctl.InstallOptions{Package: "personal/dots"}) + require.NoError(t, err) + + writeConfig(t, d, "core:\n always_installed: [personal/dots]\n") + writeProfile(t, d, "name: base\npackages: [personal/dots]\n") + // work omits and even excludes personal/dots; always_installed wins. + writeProfile(t, d, "name: work\nextends: base\npackages: [personal/zsh]\nexclude: [personal/dots]\n") + + result, err := d.ProfileApply(ctx, dotsctl.ProfileApplyOptions{Name: "work"}) + require.NoError(t, err) + require.Empty(t, result.Remove) + require.Equal(t, []string{"personal/dots"}, result.Keep) + require.Equal(t, []string{"personal/zsh"}, result.Install) + require.ElementsMatch(t, []string{"personal/dots", "personal/zsh"}, installedRefs(t, repo)) +} + +func TestProfileApply_MissingProfile(t *testing.T) { + d, _ := newTestDots(t) + _, err := d.ProfileApply(context.Background(), dotsctl.ProfileApplyOptions{Name: "nope"}) + require.ErrorIs(t, err, dots.ErrNotExist) +} + +func TestStatus_ActiveProfileFromLockfile(t *testing.T) { + d, repo := newTestDots(t) + ctx := context.Background() + seedPackage(t, d, repo, "t", "a") + writeProfile(t, d, "name: p\npackages: [t/a]\n") + _, err := d.ProfileApply(ctx, dotsctl.ProfileApplyOptions{Name: "p"}) + require.NoError(t, err) + + status, err := d.Status(ctx) + require.NoError(t, err) + require.Equal(t, "p", status.Profile) +} + +// --- Init --apply --- + +// seedBootstrapPackage creates a "dots" package in tap whose only link is the +// given config.yaml, mirroring the docs' dots package pattern. +func seedBootstrapPackage(t *testing.T, d *dotsctl.Dots, repo *dots.MemoryRepo, tap, config string) { + t.Helper() + pkgDir := filepath.Join(d.PathService.TapsDir(), tap, "dots") + require.NoError(t, os.MkdirAll(pkgDir, 0o755)) + manifest := []byte("package:\n name: dots\nlinks:\n config.yaml: \"@config/dots/config.yaml\"\n") + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "Dotfile.yaml"), manifest, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "config.yaml"), []byte(config), 0o644)) + require.NoError(t, repo.AddTap(context.Background(), dots.TapInfo{Name: tap, URL: "test"})) + require.NoError(t, repo.AddPackage(tap, dots.PackageInfo{Tap: tap, Name: "dots", Dir: "dots"}, manifest)) +} + +func TestInit_ApplyKeepsBootstrapPackage(t *testing.T) { + d, repo := newTestDots(t) + ctx := context.Background() + seedBootstrapPackage(t, d, repo, "personal", "core:\n always_installed: [personal/dots]\n") + seedPackage(t, d, repo, "personal", "zsh") + // work never mentions personal/dots; always_installed from the + // bootstrapped config must keep it. + writeProfile(t, d, "name: work\npackages: [personal/zsh]\n") + + err := d.Init(ctx, dotsctl.InitOptions{ + From: "git@github.com:me/dotfiles.git", + Name: "personal", + Path: "dots", + Apply: "work", + }) + require.NoError(t, err) + + require.ElementsMatch(t, []string{"personal/dots", "personal/zsh"}, installedRefs(t, repo)) + lf, err := repo.ReadLockfile(ctx) + require.NoError(t, err) + require.Equal(t, "work", lf.State.ActiveProfile) +} + +func TestInit_ApplyMissingProfileAfterBootstrap(t *testing.T) { + d, repo := newTestDots(t) + ctx := context.Background() + seedBootstrapPackage(t, d, repo, "personal", "core: {}\n") + + err := d.Init(ctx, dotsctl.InitOptions{ + From: "git@github.com:me/dotfiles.git", + Name: "personal", + Path: "dots", + Apply: "nope", + }) + require.ErrorIs(t, err, dots.ErrNotExist) + require.Contains(t, err.Error(), "init completed") + // The bootstrap install still happened. + require.Equal(t, []string{"personal/dots"}, installedRefs(t, repo)) +} + +// --- Editing --- + +func TestProfileCreate_WithExtends(t *testing.T) { + d, _ := newTestDots(t) + ctx := context.Background() + writeProfile(t, d, "name: base\npackages: [t/a]\n") + writeProfile(t, d, "name: dev\npackages: [t/b]\n") + + require.NoError(t, d.ProfileCreate(ctx, dotsctl.ProfileCreateOptions{Name: "work", Extends: []string{"base", "dev", "base"}})) + + show, err := d.ProfileShow(ctx, "work") + require.NoError(t, err) + require.Equal(t, dots.StringList{"base", "dev"}, show.Profile.Extends) + require.Equal(t, []string{"t/a", "t/b"}, refs(show.Resolved)) +} + +func TestProfileCreate_MissingParent(t *testing.T) { + d, _ := newTestDots(t) + ctx := context.Background() + + err := d.ProfileCreate(ctx, dotsctl.ProfileCreateOptions{Name: "work", Extends: []string{"nope"}}) + require.ErrorIs(t, err, dots.ErrNotExist) + + _, err = d.ProfileShow(ctx, "work") + require.ErrorIs(t, err, dots.ErrNotExist, "a rejected create must not leave a file behind") +} + +func TestProfileExtend_RejectsCycle(t *testing.T) { + d, _ := newTestDots(t) + ctx := context.Background() + writeProfile(t, d, "name: base\n") + writeProfile(t, d, "name: work\nextends: base\n") + + err := d.ProfileExtend(ctx, "base", []string{"work"}) + var cycle *dots.ProfileCycleError + require.ErrorAs(t, err, &cycle) + require.Equal(t, []string{"base", "work", "base"}, cycle.Chain) + + show, err := d.ProfileShow(ctx, "base") + require.NoError(t, err) + require.Empty(t, show.Profile.Extends, "a rejected extend must not be saved") +} + +func TestProfileExtendUnextend(t *testing.T) { + d, _ := newTestDots(t) + ctx := context.Background() + writeProfile(t, d, "name: base\n") + writeProfile(t, d, "name: dev\n") + writeProfile(t, d, "name: work\n") + + require.NoError(t, d.ProfileExtend(ctx, "work", []string{"base", "dev", "base"})) + show, err := d.ProfileShow(ctx, "work") + require.NoError(t, err) + require.Equal(t, dots.StringList{"base", "dev"}, show.Profile.Extends) + + require.NoError(t, d.ProfileUnextend(ctx, "work", []string{"base"})) + show, err = d.ProfileShow(ctx, "work") + require.NoError(t, err) + require.Equal(t, dots.StringList{"dev"}, show.Profile.Extends) +} + +func TestProfileExcludeUnexclude(t *testing.T) { + d, _ := newTestDots(t) + ctx := context.Background() + writeProfile(t, d, "name: base\npackages: [t/a, t/b]\n") + writeProfile(t, d, "name: work\nextends: base\n") + + require.NoError(t, d.ProfileExclude(ctx, "work", []string{"t/a", "t/zzz"})) + show, err := d.ProfileShow(ctx, "work") + require.NoError(t, err) + require.Equal(t, []string{"t/a", "t/zzz"}, show.Profile.Exclude) + require.Equal(t, []string{"t/b"}, refs(show.Resolved)) + require.Equal(t, []string{"t/zzz"}, show.UnmatchedExcludes) + + require.NoError(t, d.ProfileUnexclude(ctx, "work", []string{"t/a", "t/zzz"})) + show, err = d.ProfileShow(ctx, "work") + require.NoError(t, err) + require.Empty(t, show.Profile.Exclude) + require.Equal(t, []string{"t/a", "t/b"}, refs(show.Resolved)) +} + +func TestProfileShow_IncludesAlwaysInstalled(t *testing.T) { + d, _ := newTestDots(t) + writeConfig(t, d, "core:\n always_installed: [personal/dots]\n") + writeProfile(t, d, "name: work\npackages: [personal/zsh]\n") + + show, err := d.ProfileShow(context.Background(), "work") + require.NoError(t, err) + require.Equal(t, []dotsctl.ResolvedPackage{ + {Ref: "personal/dots", Source: dotsctl.AlwaysInstalledSource}, + {Ref: "personal/zsh", Source: "work"}, + }, show.Resolved) +} diff --git a/pkg/dotsctl/dots_status.go b/pkg/dotsctl/dots_status.go index 6b837b1..435b67b 100644 --- a/pkg/dotsctl/dots_status.go +++ b/pkg/dotsctl/dots_status.go @@ -34,7 +34,6 @@ func (d *Dots) Status(ctx context.Context) (*StatusResult, error) { ConfigDir: d.PathService.ConfigDir(), StateDir: d.PathService.StateDir(), LinkStrategy: core.LinkStrategy, - Profile: core.ActiveProfile, } taps, err := d.Repo.ListTaps(ctx) @@ -45,9 +44,7 @@ func (d *Dots) Status(ctx context.Context) (*StatusResult, error) { lockfile, err := d.Repo.ReadLockfile(ctx) if err == nil { result.PackageCount = len(lockfile.Installed) - if lockfile.State.ActiveProfile != "" { - result.Profile = lockfile.State.ActiveProfile - } + result.Profile = lockfile.State.ActiveProfile } return result, nil diff --git a/pkg/dotsctl/export_test.go b/pkg/dotsctl/export_test.go new file mode 100644 index 0000000..eb4e98b --- /dev/null +++ b/pkg/dotsctl/export_test.go @@ -0,0 +1,6 @@ +package dotsctl + +// ResolveProfile exposes resolveProfile to the external test package. +func (d *Dots) ResolveProfile(name string) ([]ResolvedPackage, error) { + return d.resolveProfile(name, nil) +} diff --git a/schemas/dots-config.json b/schemas/dots-config.json index a0d261b..5a99427 100644 --- a/schemas/dots-config.json +++ b/schemas/dots-config.json @@ -79,9 +79,11 @@ "description": "Core behavior settings", "additionalProperties": false, "properties": { - "active_profile": { - "type": "string", - "description": "Currently active profile name" + "always_installed": { + "type": "array", + "description": "Packages (tap/package) included in every applied profile. They override profile excludes and are never uninstalled by profile apply. Platform overrides append to the base list.", + "items": { "type": "string" }, + "uniqueItems": true }, "conflict_strategy": { "type": "string", diff --git a/schemas/profile.json b/schemas/profile.json index 1bb5088..aa913d9 100644 --- a/schemas/profile.json +++ b/schemas/profile.json @@ -12,12 +12,24 @@ "description": "Profile name (matches the file basename)" }, "extends": { - "type": "string", - "description": "Parent profile name. The parent's packages are merged before this profile's packages, deduped." + "description": "Parent profile name, or a list of parent profile names. Parents are resolved in order and their packages merged (deduped) before this profile's exclude and packages are applied.", + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + ] }, "packages": { "type": "array", - "description": "Package references (tap/package) to install when this profile is applied", + "description": "Package references (tap/package) to install when this profile is applied. Always installed, even if also listed in exclude.", + "items": { "type": "string" } + }, + "exclude": { + "type": "array", + "description": "Package references (tap/package) to drop from the packages inherited through extends. Does not affect this profile's own packages.", "items": { "type": "string" } } }