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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/

# Taskfile sources cache
/.task/
64 changes: 64 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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_<op>.go` with an `<Op>Options` struct and a method on `*Dots`. New CLI command: add `pkg/cli/cmd_<name>.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) |
133 changes: 1 addition & 132 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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_<op>.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/<name>.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
96 changes: 77 additions & 19 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -57,6 +68,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
locales \
openssh-client \
python3 \
sudo \
tzdata \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -135,33 +148,49 @@ 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
# working directory. The fixture under containers/fixture/ is checked into the
# 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" \
&& git config user.name "dots-test build" \
&& 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 \
Expand All @@ -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"]
Loading
Loading