From 3f2fe22b7b5017e16762eac4ee8fb9df496261ea Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 4 Aug 2026 00:31:46 +0200 Subject: [PATCH 1/5] cli: Add internal hexagonal architecture skeleton and enforcement check The sce CLI had no enforced boundary preventing future domain or use-case logic from coupling directly to infrastructure concerns (CLI parsing, filesystem, process, database, HTTP). Establishing that boundary after more code accumulates would be far more expensive than introducing it now. Co-authored-by: SCE --- cli/src/adapters/inbound/cli/mod.rs | 1 + cli/src/adapters/inbound/mod.rs | 3 + cli/src/adapters/mod.rs | 8 + cli/src/adapters/outbound/mod.rs | 2 + cli/src/application/error.rs | 1 + cli/src/application/mod.rs | 11 + cli/src/application/ports/mod.rs | 1 + cli/src/application/use_cases/mod.rs | 1 + cli/src/composition.rs | 16 + cli/src/domain/mod.rs | 6 + cli/src/main.rs | 6 +- context/architecture.md | 89 ++++- ...-04-cli-internal-hexagonal-architecture.md | 105 +++++ context/glossary.md | 3 +- context/overview.md | 2 +- context/patterns.md | 2 +- .../cli-hexagonal-architecture-skeleton.md | 360 ++++++++++++++++++ flake.nix | 28 ++ scripts/check-cli-architecture.sh | 82 ++++ scripts/test-check-cli-architecture.sh | 120 ++++++ 20 files changed, 841 insertions(+), 6 deletions(-) create mode 100644 cli/src/adapters/inbound/cli/mod.rs create mode 100644 cli/src/adapters/inbound/mod.rs create mode 100644 cli/src/adapters/mod.rs create mode 100644 cli/src/adapters/outbound/mod.rs create mode 100644 cli/src/application/error.rs create mode 100644 cli/src/application/mod.rs create mode 100644 cli/src/application/ports/mod.rs create mode 100644 cli/src/application/use_cases/mod.rs create mode 100644 cli/src/composition.rs create mode 100644 cli/src/domain/mod.rs create mode 100644 context/decisions/2026-08-04-cli-internal-hexagonal-architecture.md create mode 100644 context/plans/cli-hexagonal-architecture-skeleton.md create mode 100755 scripts/check-cli-architecture.sh create mode 100755 scripts/test-check-cli-architecture.sh diff --git a/cli/src/adapters/inbound/cli/mod.rs b/cli/src/adapters/inbound/cli/mod.rs new file mode 100644 index 00000000..8d2e52cc --- /dev/null +++ b/cli/src/adapters/inbound/cli/mod.rs @@ -0,0 +1 @@ +//! CLI inbound adapter: parses command-line input and invokes use cases. diff --git a/cli/src/adapters/inbound/mod.rs b/cli/src/adapters/inbound/mod.rs new file mode 100644 index 00000000..1d875028 --- /dev/null +++ b/cli/src/adapters/inbound/mod.rs @@ -0,0 +1,3 @@ +//! Inbound adapters: entrypoints that drive the application layer. + +mod cli; diff --git a/cli/src/adapters/mod.rs b/cli/src/adapters/mod.rs new file mode 100644 index 00000000..3489e121 --- /dev/null +++ b/cli/src/adapters/mod.rs @@ -0,0 +1,8 @@ +//! Adapters layer: inbound and outbound implementations of application ports. +//! +//! Adapters may depend on `crate::application` and, transitionally, on +//! `crate::services`. See `context/architecture.md` for the full +//! dependency-direction rules. + +mod inbound; +mod outbound; diff --git a/cli/src/adapters/outbound/mod.rs b/cli/src/adapters/outbound/mod.rs new file mode 100644 index 00000000..9048de3f --- /dev/null +++ b/cli/src/adapters/outbound/mod.rs @@ -0,0 +1,2 @@ +//! Outbound adapters: implementations of application ports (storage, network, +//! and other infrastructure integrations). diff --git a/cli/src/application/error.rs b/cli/src/application/error.rs new file mode 100644 index 00000000..aceb5166 --- /dev/null +++ b/cli/src/application/error.rs @@ -0,0 +1 @@ +//! Application-level error types shared across use cases. diff --git a/cli/src/application/mod.rs b/cli/src/application/mod.rs new file mode 100644 index 00000000..76eea3ad --- /dev/null +++ b/cli/src/application/mod.rs @@ -0,0 +1,11 @@ +//! Application layer: use cases and ports orchestrating the domain. +//! +//! Application code may depend on `crate::domain` but must not depend on +//! `crate::adapters`, `crate::composition`, `crate::services`, or +//! infrastructure crates (CLI parsing, database, HTTP, process/filesystem +//! access). See `context/architecture.md` for the full dependency-direction +//! rules. + +mod error; +mod ports; +mod use_cases; diff --git a/cli/src/application/ports/mod.rs b/cli/src/application/ports/mod.rs new file mode 100644 index 00000000..975e42dc --- /dev/null +++ b/cli/src/application/ports/mod.rs @@ -0,0 +1 @@ +//! Ports: interfaces the application layer depends on and adapters implement. diff --git a/cli/src/application/use_cases/mod.rs b/cli/src/application/use_cases/mod.rs new file mode 100644 index 00000000..477c6cd4 --- /dev/null +++ b/cli/src/application/use_cases/mod.rs @@ -0,0 +1 @@ +//! Use cases: application-specific orchestration of domain and ports. diff --git a/cli/src/composition.rs b/cli/src/composition.rs new file mode 100644 index 00000000..92200919 --- /dev/null +++ b/cli/src/composition.rs @@ -0,0 +1,16 @@ +//! Composition root: wires runtime dependencies for the CLI binary. +//! +//! `run` is the sole entrypoint called by `main`. It currently delegates to +//! the legacy `app::run` runtime unchanged; ownership of dependency wiring +//! moves here incrementally as commands migrate onto the hexagonal layers. + +use std::process::ExitCode; + +use crate::app; + +pub(crate) fn run(args: I) -> ExitCode +where + I: IntoIterator, +{ + app::run(args) +} diff --git a/cli/src/domain/mod.rs b/cli/src/domain/mod.rs new file mode 100644 index 00000000..a9b2da67 --- /dev/null +++ b/cli/src/domain/mod.rs @@ -0,0 +1,6 @@ +//! Domain layer: pure business types and rules for the CLI. +//! +//! Domain code must not depend on `crate::adapters`, `crate::application`, +//! `crate::composition`, `crate::services`, or any infrastructure crate +//! (CLI parsing, database, HTTP, process/filesystem/env access). See +//! `context/architecture.md` for the full dependency-direction rules. diff --git a/cli/src/main.rs b/cli/src/main.rs index 5f4ee707..9106782a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,10 @@ +mod adapters; mod app; +mod application; mod cli_schema; mod command_surface; +mod composition; +mod domain; #[allow(dead_code)] mod generated_migrations { include!(concat!(env!("OUT_DIR"), "/generated_migrations.rs")); @@ -10,5 +14,5 @@ mod services; use std::process::ExitCode; fn main() -> ExitCode { - app::run(std::env::args()) + composition::run(std::env::args()) } diff --git a/context/architecture.md b/context/architecture.md index 806451e3..1b60625e 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -37,7 +37,7 @@ Current target renderer helper modules: - `config/pkl/generator-inputs.txt` (machine-readable repository-relative declaration of canonical Pkl and referenced plugin/extension inputs) - `scripts/produce-cli-generated-input.sh` (canonical generated-input producer for input discovery, two-pass evaluation, determinism and input-mutation checks, exact payload/input inventories, atomic publication, and temporary-state cleanup; consumed by the repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation) - `config/pkl/check-generated.sh` (dev-shell integration check that delegates deterministic generation and inventories to the producer while retaining metadata/contract fixtures, required outputs, forbidden repository generated paths, and the stray repository-local `config/pkl/rendered` evaluation artifact) -- `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,pkl-generated,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format,workflow-actionlint}` plus Linux-only `flatpak-static-validation`, `cargo-sources-parity`, and `flatpak-manifest-parity` (root-flake checks for CLI behavior, ephemeral Pkl generation, JS validation, workflow linting, and lightweight Flatpak validation) +- `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,pkl-generated,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format,workflow-actionlint,cli-architecture}` plus Linux-only `flatpak-static-validation`, `cargo-sources-parity`, and `flatpak-manifest-parity` (root-flake checks for CLI behavior, ephemeral Pkl generation, JS validation, workflow linting, the CLI internal-layer dependency check, and lightweight Flatpak validation) - `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden fixture coverage (fully Rust-owned; the Claude TypeScript Bun test was removed in T07). The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, the Pi extension entrypoint, generated OpenCode package manifests, and generated Claude project settings). @@ -81,6 +81,91 @@ See `context/decisions/2026-07-27-workflow-oriented-pkl-generation.md` for the c The repository includes a new placeholder Rust binary crate at `cli/`. +## CLI internal hexagonal architecture + +The `sce` CLI stays one Cargo package (`cli/`, binary `sce`); there is no +plan to split it into multiple crates. Hexagonal architecture here is a +*module-boundary and dependency-direction* discipline enforced inside that +one package, not a statement about crate count. `cli/src/{domain, +application, adapters}/` are crate-private (`mod`, not `pub`) module trees +introduced as an explicit skeleton; `cli/src/composition.rs` is the +composition root. Every one of these is currently doc-comment-only or a thin +delegator — no command, service, or domain logic has moved out of +`cli/src/services/**` yet. + +Layer ownership: + +- `cli/src/domain/` — pure business types and rules. No dependency on + `crate::adapters`, `crate::application`, `crate::composition`, + `crate::services`, or any infrastructure crate/module (CLI parsing, + database, HTTP, process/filesystem/env access). +- `cli/src/application/` (`error.rs`, `ports/`, `use_cases/`) — use cases and + the ports they depend on, orchestrating the domain. May depend on + `crate::domain`. Must not depend on `crate::adapters`, + `crate::composition`, `crate::services`, or infrastructure crates/modules. +- `cli/src/adapters/inbound/` (currently `cli/`) — entrypoints that drive the + application layer (e.g. a future CLI-parsing adapter invoking use cases). +- `cli/src/adapters/outbound/` — implementations of application-owned ports + (storage, network, and other infrastructure integrations). +- `cli/src/composition.rs` — the composition root. `composition::run` is the + sole entrypoint `main.rs` calls; it wires concrete adapters to application + ports. During this transitional phase it delegates unchanged to the legacy + `app::run` runtime. + +Adapters depend inward on ports the application layer owns: an outbound +adapter implements a trait defined under `cli/src/application/ports/`, and +the application layer never depends on a concrete adapter type. `adapters` +and `composition` may transitionally depend on `crate::services` (the +existing command/runtime implementation); `domain` and `application` may +never depend on `crate::services` under any circumstance — that restriction +is permanent, not just for this phase. + +`cli/src/services/**` (`app.rs`, `cli_schema.rs`, `command_surface.rs`, and +everything under `cli/src/services/`) is the CLI's existing implementation +and remains the runtime behavior owner. It is a temporary compatibility +namespace from the hexagonal skeleton's point of view: new domain and +application code must never depend on it, and its logic is expected to move +into `domain`/`application`/`adapters` over time, not to gain new +dependents. + +Migration proceeds through vertical slices rather than a big-bang rewrite: +one command or capability at a time gets domain types, application use +cases/ports, and adapters carved out of `services`, with `composition::run` +progressively wiring more of the CLI through the new layers while +unconverted commands keep flowing through `app::run`. No slice migration is +in scope for this skeleton phase. + +```mermaid +flowchart LR + subgraph adapters["adapters"] + inbound["inbound (cli/)"] + outbound["outbound"] + end + application["application (ports, use_cases, error)"] + domain["domain"] + composition["composition (composition root)"] + services["services (temporary, transitional)"] + + inbound --> application + outbound --> application + application --> domain + composition --> adapters + composition --> services + adapters -. transitional .-> services +``` + +A deterministic, network-free shell script, +`scripts/check-cli-architecture.sh`, enforces the permanent `domain` and +`application` restrictions by scanning `cli/src/domain/**/*.rs` and +`cli/src/application/**/*.rs` for forbidden imports (`clap`, `turso`, +`reqwest`, `inquire`, `keyring_core`, `std::fs`, `std::process`, plus +`std::env` and `crate::application` for `domain` only, and +`crate::adapters`/`crate::composition`/`crate::services` for both); it does +not enforce rules on `adapters` or `composition`, which may transitionally +depend on `services`. `scripts/test-check-cli-architecture.sh` proves the +check against fixture trees, and `nix flake check` runs both scripts through +the `cli-architecture` check. + ## CLI install/distribution boundary - The current implemented binary install/distribution surface for the `sce` CLI includes repo-flake Nix, Cargo, and npm; `Homebrew` is deferred from the active implementation stage. @@ -100,7 +185,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - Downstream registry publication is now implemented in dedicated workflows: `.github/workflows/publish-crates.yml` publishes the checked-in crate version from a published release or manual dispatch after `.version`/tag/Cargo parity checks and requires semver prerelease metadata when a publish run is marked prerelease; `.github/workflows/publish-npm.yml` publishes the checked-in npm package version from a published release or manual dispatch after `.version`/tag/npm parity checks plus verification of the canonical `sce-v-npm.tgz` GitHub release asset, using the `next` npm dist-tag instead of `latest` for prerelease versions or prerelease-marked runs. - The npm distribution implementation lives under `npm/`: `package.json` defines the `sce` package surface, `bin/sce.js` launches the package-local native binary, `lib/install.js` resolves the current package version against the release manifest, verifies `sce-v-release-manifest.json.sig` with the bundled public key before trusting manifest contents, and then installs the checksum-verified native archive for supported macOS/Linux targets, while `test/platform.test.js` and `test/install.test.js` cover platform selection plus signed-manifest installer behavior. -- `cli/src/main.rs` is the executable entrypoint (`sce`) and delegates to `app::run`. +- `cli/src/main.rs` is the executable entrypoint (`sce`) and delegates to `composition::run`, which in turn delegates to `app::run` unchanged. `cli/src/composition.rs` is the CLI's composition root; `cli/src/domain/`, `cli/src/application/`, and `cli/src/adapters/` are new crate-private, module-doc-only internal layer skeletons with no behavior yet (see the planned CLI internal hexagonal architecture section for the full dependency-direction rules once populated). - `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the `trace db shell ` surface, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth status`). - `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, bare-command help routing for `sce auth` and `sce config`, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. - The app runtime now moves through explicit startup phases in `cli/src/app.rs`: dependency bootstrapping (`perform_dependency_check`), startup context construction (`build_startup_context`), runtime initialization (`initialize_runtime`), command parse/execute inside telemetry subscriber context (`run_command_lifecycle`, `parse_command_phase` plus `services::app_support::execute_command_phase`), and final output rendering through `services::app_support::render_run_outcome`. `AppRuntime` owns the concrete production logger, no-op telemetry runtime, filesystem ops, git ops, static `CommandRegistry`, and startup-diagnostic state across those phases; `RunOutcome` carries final render data with an optional generic logger implementing `services::observability::traits::Logger`, so render support can log classified errors without production-logger type coupling. If a telemetry implementation attempts to invoke the command action more than once, dispatch returns a runtime-classified error instead of panicking or reusing consumed arguments. diff --git a/context/decisions/2026-08-04-cli-internal-hexagonal-architecture.md b/context/decisions/2026-08-04-cli-internal-hexagonal-architecture.md new file mode 100644 index 00000000..cbaf3a2d --- /dev/null +++ b/context/decisions/2026-08-04-cli-internal-hexagonal-architecture.md @@ -0,0 +1,105 @@ +# Decision: Adopt an internal hexagonal architecture for the sce CLI + +Date: 2026-08-04 +Status: Accepted +Plan: `context/plans/cli-hexagonal-architecture-skeleton.md` +Task: T01, T02, T03, T04, T05 + +## Context + +The `sce` CLI (`cli/`) is one Cargo package whose command, service, and +runtime logic all live under `cli/src/services/**`, `app.rs`, +`cli_schema.rs`, and `command_surface.rs`. As the CLI grows, there was no +enforced boundary preventing future domain or use-case logic from directly +coupling to infrastructure concerns (CLI parsing, filesystem, process, +environment, database, HTTP). Introducing such a boundary after the fact, +once more code exists, would be far more expensive than establishing it +before further growth. + +## Decision + +The CLI adopts hexagonal architecture as a permanent, enforced internal +module-boundary and dependency-direction discipline within its single Cargo +package: `cli/src/domain/` (pure business types/rules) and +`cli/src/application/` (`error.rs`, `ports/`, `use_cases/`) must never depend +on `crate::adapters`, `crate::composition`, `crate::services`, or +infrastructure crates/modules (`clap`, `turso`, `reqwest`, `inquire`, +`keyring_core`, `std::fs`, `std::env`, `std::process`); `cli/src/adapters/` +(`inbound/`, `outbound/`) implement application-owned ports and may +transitionally depend on `crate::services`; `cli/src/composition.rs` is the +sole composition root `main.rs` calls, currently delegating to the legacy +`app::run`. `cli/src/services/**` remains a temporary compatibility +namespace holding all current runtime behavior, to be migrated into the new +layers through future vertical slices, one command/capability at a time. + +## Rationale + +A deterministic, network-free shell script +(`scripts/check-cli-architecture.sh`) can mechanically enforce the +domain/application restriction without new crate dependencies, dynamic +dispatch, or a general-purpose rule engine, and wiring it into +`nix flake check` makes the restriction self-enforcing from day one rather +than relying on review discipline. Establishing the module skeleton and the +permanent restriction now, while `services/**` is left completely untouched +and behavior-identical, decouples the boundary decision from any migration +risk. + +## Alternatives considered + +- **Split the CLI into multiple crates** — rejected; the plan explicitly + keeps the CLI a single Cargo package, since crate-count is orthogonal to + achieving dependency-direction discipline and would add build/tooling + overhead. +- **Migrate `services/**` logic into the new layers immediately (big-bang + rewrite)** — rejected as materially riskier than an incremental + vertical-slice migration; deferred to future plans. +- **A general-purpose architecture-rule engine** — rejected as + disproportionate; a deterministic script covering exactly the permanent + `domain`/`application` restrictions is sufficient and simpler to audit. + +## Compatibility and risks + +- No behavior, CLI output, exit codes, or command surface changes: this + phase adds only doc-comment-only modules and a thin `composition::run` + delegator to `app::run`. +- Risk: the architecture check only scans `domain`/`application`, not + `adapters`/`composition`, which may transitionally depend on `services`; + this is an intentional, narrow scope, not a gap to be closed reactively. +- Migration risk (moving logic out of `services/**`) is deferred entirely to + future plans and is not taken on by this decision. + +## Guardrails + +- `domain` and `application` must never depend on `crate::services`, under + any circumstance, permanently — this is not a transitional-phase-only + rule. +- The check enforces only the domain/application restrictions; it does not + attempt to constrain `adapters` or `composition`. +- No new crate dependencies, no dynamic dispatch, no boxed service + registries were introduced to achieve this. + +## Consequences + +- Future CLI plans have an existing, CI-enforced module skeleton and + dependency rule to build vertical-slice migrations against, instead of + needing to invent one per plan. +- `scripts/check-cli-architecture.sh` and `scripts/test-check-cli-architecture.sh` + are now permanent parts of the validation surface (`nix flake check`) that + any future change touching `cli/src/domain/**` or `cli/src/application/**` + must satisfy. +- `services/**` is now documented as explicitly temporary; new code should + default to landing in the new layers rather than growing `services/**` + further where avoidable. + +## Follow-up + +- None. This decision covers the skeleton only; specific vertical-slice + migrations out of `services/**` are future plans, not committed follow-up + work. + +## References + +- Plan: [`cli-hexagonal-architecture-skeleton`](../plans/cli-hexagonal-architecture-skeleton.md) +- Task: `T01`, `T02`, `T03`, `T04`, `T05` +- Current-state context: [`context/architecture.md`](../architecture.md) (`## CLI internal hexagonal architecture`) +- Evidence: [`context/plans/cli-hexagonal-architecture-skeleton.md`](../plans/cli-hexagonal-architecture-skeleton.md) (`## Validation Report`) diff --git a/context/glossary.md b/context/glossary.md index da17bdd0..8d553afa 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -38,7 +38,8 @@ - `checkout registry` (removed): The central JSON registry at `/sce/checkout-registry.json` was removed in the `remove-checkout-registry` plan. `sce trace db list` now discovers checkouts by scanning `/sce/agent-trace-*.db` files on disk. `checkout_id`, `database_path`, and `last_seen` (from file mtime) are derived from the filesystem; `path` and `remote_url` are no longer rendered. See `context/cli/checkout-identity.md`. - `generated OpenCode plugin registration contract`: Current generated-config contract where `config/.opencode/opencode.json` serializes the OpenCode `plugin` field from canonical Pkl sources for SCE-managed plugins only; the current registered paths are `./plugins/sce-bash-policy.ts` and `./plugins/sce-agent-trace.ts`. Claude does not use an OpenCode-style plugin manifest; Claude bash-policy enforcement is registered through generated `.claude/settings.json` as a `PreToolUse` `Bash` command hook running `sce policy bash`. - `root Biome contract`: Repository-root formatting/linting contract owned by `biome.json`, currently scoped only to `npm/**` and the shared `config/lib/**` plugin package root with package-local `node_modules/**` excluded; the canonical execution path is the root Nix dev shell (`nix develop -c biome ...`). -- `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), plus `pkl-generated`, `workflow-actionlint`, split npm/config-lib JS checks, and Linux-only Flatpak checks; invoked via `nix flake check` at repo root. +- `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), plus `pkl-generated`, `workflow-actionlint`, `cli-architecture`, split npm/config-lib JS checks, and Linux-only Flatpak checks; invoked via `nix flake check` at repo root. +- `cli-architecture` (flake check): Root-flake check derivation (`cliArchitectureCheck` in `flake.nix`) that copies `scripts/check-cli-architecture.sh`, `scripts/test-check-cli-architecture.sh`, `cli/src/domain`, and `cli/src/application` into a writable sandbox copy, `patchShebangs`es the copied scripts (the sandbox has no `/usr/bin/env`), then runs both scripts to enforce the CLI's internal hexagonal-architecture dependency rules and prove the check's fixture-backed accept/reject assertions. - `npm JS flake checks`: The current `npm/` validation slice exposed by root `flake.nix`: `npm-bun-tests` runs only `bun test ./test/*.test.js`, `npm-biome-check` runs only Biome lint/check with formatter verification disabled, and `npm-biome-format` runs only Biome format verification with linter checks disabled. - `config-lib JS flake checks`: The current shared `config/lib/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs Bun-discovered tests from the copied shared `config/lib/` package source (including bash-policy plugin wrapper tests and tracked agent-trace plugin tests), with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`, while `config-lib-biome-check` and `config-lib-biome-format` run Biome lint/check and format verification over the copied shared package source with formatter/linter halves disabled respectively. - `config-lib repo-shaped test source`: Root-flake source-layout contract where `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format` run from `config/lib/` while their copied Nix source preserves repo-relative shared fixtures, currently `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden tests (fully Rust-owned; the Claude TypeScript Bun test was removed in T07). diff --git a/context/overview.md b/context/overview.md index 7008b7cf..8f150476 100644 --- a/context/overview.md +++ b/context/overview.md @@ -85,7 +85,7 @@ The setup command parser/dispatch now also supports composable setup+hooks runs - Generate a temporary preview: `nix run .#pkl-generate -- "$(mktemp -d)"` - Verify deterministic ephemeral outputs and forbidden repository paths: `nix run .#pkl-check-generated` -- Run repository flake checks (CLI tests, clippy, fmt, `pkl-generated`, workflow-actionlint): `nix flake check` +- Run repository flake checks (CLI tests, clippy, fmt, `pkl-generated`, workflow-actionlint, cli-architecture): `nix flake check` Lightweight post-task verification baseline (required after each completed task): run `nix run .#pkl-check-generated` and `nix flake check`. diff --git a/context/patterns.md b/context/patterns.md index 713e6f07..85b13764 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -166,7 +166,7 @@ - For hosted rewrite mapping seams, resolve candidates deterministically in strict precedence order (patch-id exact, then range-diff score, then fuzzy score), classify top-score ties as `ambiguous`, enforce low-confidence unresolved behavior below `0.60`, and preserve stable outcome ordering via canonical candidate SHA sorting. - For hosted reconciliation observability, publish run-level mapped/unmapped counts, confidence histogram buckets, runtime timing, and normalized error-class labels so retry/quality drift can be monitored without requiring a full dashboard surface. - Keep crate-local onboarding docs in `cli/README.md` and sanity-check command examples against actual `sce` output whenever command messaging changes. -- Keep Rust verification in flake checks under stable named derivations re-exported by the root flake: `checks..cli-tests`, `checks..cli-clippy`, `checks..cli-fmt`, and `checks..workflow-actionlint`. +- Keep Rust verification in flake checks under stable named derivations re-exported by the root flake: `checks..cli-tests`, `checks..cli-clippy`, `checks..cli-fmt`, `checks..workflow-actionlint`, and `checks..cli-architecture` (runs `scripts/check-cli-architecture.sh` and `scripts/test-check-cli-architecture.sh` against a copied `cli/src/domain`/`cli/src/application` plus `scripts/` source, following the `pklGeneratedCheck` `patchShebangs` pattern since the sandbox has no `/usr/bin/env`). - Keep cheap flake-check sources as narrow as their behavior allows: formatting checks should not depend on generated payloads, and ephemeral/static checks should copy only the canonical inputs and forbidden paths they inspect. - Keep Rust package/check sources as narrow as behavior allows: one deterministic pre-Cargo Nix derivation receives the shared generated-input producer plus its canonical Pkl and referenced plugin/extension sources, invokes that producer once, and passes its validated generated-input store path to every Crane derivation that compiles the CLI (native, release, test, and Clippy). Keep Pkl out of those Cargo environments, and do not attach the handoff to dependency-only or formatting derivations so generated-input changes preserve dependency caches and formatting independence. Neither boundary may include committed generated target trees. - In `flake.nix`, select the Rust toolchain via an explicit Rust overlay (`rust-overlay`) and thread that toolchain through Crane package/check derivations so CLI builds and checks do not rely on implicit nixpkgs Rust defaults. diff --git a/context/plans/cli-hexagonal-architecture-skeleton.md b/context/plans/cli-hexagonal-architecture-skeleton.md new file mode 100644 index 00000000..397812da --- /dev/null +++ b/context/plans/cli-hexagonal-architecture-skeleton.md @@ -0,0 +1,360 @@ +# Plan: cli-hexagonal-architecture-skeleton + +## Change summary + +Introduce explicit internal hexagonal architecture boundaries inside the existing +single-crate `sce` CLI (`cli/`): new `domain`, `application`, `adapters`, and +`composition` modules under `cli/src/`, a `composition::run` entrypoint that +`main.rs` calls in place of `app::run`, a deterministic shell-based architecture +check (`scripts/check-cli-architecture.sh`) that enforces the permanent +dependency restrictions on `domain` and `application`, fixture-backed tests +proving the check rejects and accepts the required cases, a new root Nix check +wiring the architecture check into `nix flake check`, and a focused new section +in `context/architecture.md` documenting the four layers, their dependency +rules, and the transitional role of `services`. + +This is new structure, not a migration: `app.rs`, `cli_schema.rs`, +`command_surface.rs`, and every file under `cli/src/services/` are untouched +except for `main.rs`'s one-line routing change. `composition::run` delegates to +the existing `app::run` runtime unchanged, so CLI behavior, exit codes, output, +and the public command surface do not change. + +## Acceptance criteria + +- [x] AC1: The CLI remains one Cargo package and exposes explicit internal + modules for `domain`, `application`, `adapters`, and `composition`. + - Validate: `test -f cli/src/domain/mod.rs && test -f cli/src/application/mod.rs && test -f cli/src/adapters/mod.rs && test -f cli/src/composition.rs` +- [x] AC2: `main.rs` calls `composition::run`, which delegates to the existing + `app` runtime without changing behavior. + - Validate: `grep -n "composition::run" cli/src/main.rs` and `cargo test --manifest-path cli/Cargo.toml` +- [x] AC3: `context/architecture.md` documents the four internal layers, the + allowed dependency direction, the transitional role of `services`, and the + vertical-slice migration strategy. + - Validate: `grep -n "Hexagonal" context/architecture.md` and `grep -n "services" context/architecture.md` +- [x] AC4: The architecture check fails when domain code imports `crate::adapters`, + `crate::application`, `crate::composition`, `crate::services`, or a forbidden + infrastructure dependency (including `std::fs`, `std::env`, `std::process`). + - Validate: `scripts/check-cli-architecture.sh` against the real tree, plus + `scripts/test-check-cli-architecture.sh` negative fixtures for domain + violations +- [x] AC5: The architecture check fails when application code imports + `crate::adapters`, `crate::composition`, `crate::services`, or a forbidden + infrastructure dependency (including `std::fs`, `std::process`). + - Validate: `scripts/check-cli-architecture.sh` against the real tree, plus + `scripts/test-check-cli-architecture.sh` negative fixtures for application + violations +- [x] AC6: The check accepts the required positive cases: domain code using + `std::path::PathBuf`, application code importing `crate::domain`, adapter + code importing `crate::application`, and composition code delegating to the + legacy `app` module. + - Validate: `scripts/test-check-cli-architecture.sh` +- [x] AC7: Existing CLI tests, formatting, linting, and the generated-asset + pipeline continue to pass unchanged. + - Validate: `nix flake check` + +### Full validation + +- `nix flake check` +- `cargo test --manifest-path cli/Cargo.toml` +- `scripts/check-cli-architecture.sh` +- `scripts/test-check-cli-architecture.sh` + +### Context sync + +- `context/architecture.md` (new hexagonal architecture section; already an + in-scope task deliverable, not a post-hoc sync) +- `context/cli/cli-command-surface.md` (note the new module-boundary layer if + its module-boundary description would otherwise go stale) + +## Constraints and non-goals + +- **In scope:** `cli/src/main.rs`, new `cli/src/composition.rs`, new + `cli/src/domain/`, `cli/src/application/`, `cli/src/adapters/` module trees, + `scripts/check-cli-architecture.sh`, `scripts/test-check-cli-architecture.sh`, + `flake.nix` (one new check derivation), `context/architecture.md`. +- **Out of scope:** any change to `cli/src/services/**`, `cli/src/app.rs`, + `cli/src/cli_schema.rs`, `cli/src/command_surface.rs`, `cli/build.rs`, + command behavior, help output, exit codes, JSON output, generated asset + paths, `AppContext`, `ServiceLifecycle`, `CommandRegistry`. +- **Constraints:** single Cargo package, unchanged package/binary name, no new + crate dependencies, no dynamic dispatch, no boxed service registries, new + modules default to `mod` (crate-private) visibility with a narrow surface per + layer, the architecture check must run without network access and require no + tool beyond what the repository environment already has (bash/grep/find). +- **Non-goal:** migrating any command, service, or domain logic out of + `services/**` in this phase. `application/ports` and `domain` stay + documentation-only beyond the minimum structural types needed to compile; + no speculative `FileSystem`/`Database`/`HttpClient`/`Clock`/`Logger` traits. +- **Non-goal:** a general-purpose architecture-rule engine. The check is a + deterministic shell script covering exactly the permanent restrictions on + `domain` and `application`; it does not enforce rules on `adapters` or + `composition`, which may transitionally depend on `services`. + +## Task stack + +- [x] T01: `Add domain/application/adapters module skeleton and composition root` (status:done) + - Task ID: T01 + - Goal: Create the four internal layer modules (`domain`, `application` with + `error.rs`/`ports/`/`use_cases/`, `adapters` with `inbound/cli/` and + `outbound/`, and `composition.rs`) as crate-private modules with + module-level documentation only, wire them into `main.rs`, and make + `composition::run` delegate to `app::run`. Update `main.rs` to call + `composition::run` instead of `app::run`. + - Boundaries (in/out of scope): In — new module files/dirs under + `cli/src/{domain,application,adapters}/`, new `cli/src/composition.rs`, + the `mod` declarations and one-line dispatch change in `cli/src/main.rs`. + Out — any change to `app.rs`'s internals, `services/**`, or runtime + initialization order. + - Dependencies: none + - Done when: `cargo build --manifest-path cli/Cargo.toml` succeeds with the + new modules present and unused-code-clean (module docs only, no dead + code warnings), `main.rs` calls `composition::run`, and + `cargo test --manifest-path cli/Cargo.toml` passes with no behavior change. + - Verification notes (commands or checks): `cargo build --manifest-path cli/Cargo.toml`; `cargo test --manifest-path cli/Cargo.toml`; `cargo clippy --manifest-path cli/Cargo.toml --all-targets --all-features`; `./sce --help` (via `cargo run --manifest-path cli/Cargo.toml -- --help`) output unchanged versus current `main`. + - Implementation evidence: Added crate-private `cli/src/domain/mod.rs`, + `cli/src/application/{mod.rs,error.rs,ports/mod.rs,use_cases/mod.rs}`, + `cli/src/adapters/{mod.rs,inbound/mod.rs,inbound/cli/mod.rs,outbound/mod.rs}` + (module-doc-only, no items), and `cli/src/composition.rs` with + `pub(crate) fn run` delegating unchanged to `crate::app::run`. `main.rs` + now declares the four new `mod` items and calls `composition::run(std::env::args())` + in place of `app::run(...)`. `app.rs`, `cli_schema.rs`, `command_surface.rs`, + and `services/**` are untouched. + - Verification commands and outcomes: `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml` + (clean build, no warnings); `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` + (183 passed, 0 failed); `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets --all-features -- -D warnings` + (clean); `./scripts/run-cli-cargo.sh run --manifest-path cli/Cargo.toml -- --help` + (banner/usage/commands output unchanged). + - Deviations/assumptions: Used `./scripts/run-cli-cargo.sh` (the repository's + canonical Cargo boundary, per `context/patterns.md`) in place of bare + `cargo`, since repository builds require the `SCE_CLI_GENERATED_INPUT_DIR` + handoff that only this wrapper (or Nix) provides; this does not change + scope or command intent. + +- [x] T02: `Add the deterministic architecture validation script` (status:done) + - Task ID: T02 + - Goal: Add `scripts/check-cli-architecture.sh`, a network-free, tool-free + (bash/grep/find only) script that scans `cli/src/domain/**/*.rs` and + `cli/src/application/**/*.rs`, flags full-line-comment matches only on a + best-effort basis, and fails with the offending file, line, and matched + dependency for any forbidden import listed in the plan's dependency rules + (domain: `clap`, `turso`, `reqwest`, `inquire`, `keyring_core`, `std::fs`, + `std::env`, `std::process`, `crate::adapters`, `crate::application`, + `crate::composition`, `crate::services`; application: the same set minus + `std::env` and `crate::application`). It must not flag `std::path::Path`, + `std::path::PathBuf`, `std::time::Duration`, or ordinary collection/ + formatting imports. Support an override (e.g. `CLI_ARCH_CHECK_ROOT`) so + tests can point it at a fixture tree instead of the real repository. + - Boundaries (in/out of scope): In — the check script only. Out — wiring it + into Nix (T04), fixtures/tests (T03), and any rule enforcement on + `adapters`/`composition` (explicitly not required this phase). + - Dependencies: T01 + - Done when: `scripts/check-cli-architecture.sh` exits `0` against the real + `cli/src/domain` and `cli/src/application` trees produced by T01, and + exits non-zero with a deterministic offending-file/line message when run + against a manually constructed violation (verified ad hoc during this + task; the durable fixture proof is T03). + - Verification notes (commands or checks): `scripts/check-cli-architecture.sh`; manual run against a scratch temp copy with an injected `use crate::services;` line inside `cli/src/domain` to confirm non-zero exit and a clear diagnostic. + - Implementation evidence: Added `scripts/check-cli-architecture.sh` (executable). + It resolves an optional `CLI_ARCH_CHECK_ROOT` override (defaulting to the + real repository root), scans `cli/src/domain/**/*.rs` and + `cli/src/application/**/*.rs` via `find ... -print0`, skips lines that are + entirely a `//` comment on a best-effort basis, and matches each layer's + forbidden-token list with a boundary-guarded bash regex + (`(^|[^A-Za-z0-9_:])token([^A-Za-z0-9_]|$)`) so `std::fs`/`crate::adapters`/ + etc. match only as whole path segments and never match + `std::path::PathBuf`, `std::path::Path`, `std::time::Duration`, or other + unrelated imports. On a match it prints `file:line: forbidden dependency + in layer: ` to stderr and exits non-zero; with no + violations it prints a one-line pass message and exits 0. + - Verification commands and outcomes: `./scripts/check-cli-architecture.sh` + against the real tree (exit 0, pass message); a scratch `mktemp -d` copy of + `cli/src/domain` and `cli/src/application` with `use crate::services;` + appended to `domain/mod.rs`, run via `CLI_ARCH_CHECK_ROOT= + ./scripts/check-cli-architecture.sh` (exit 1, printed + `.../domain/mod.rs:7: forbidden dependency in domain layer: + crate::services`); a second scratch fixture with a domain file using + `std::path::PathBuf`, `std::path::Path`, `std::time::Duration`, + `std::collections::HashMap`, and comment-only mentions of + `crate::adapters`/`crate::services`, plus an application file importing + `crate::domain` (exit 0, no false positives). + - Deviations/assumptions: Diagnostic wording and exit-code convention follow + the style of existing repository check scripts (e.g. + `config/pkl/check-generated.sh`); the script reports every violation found + in a single run rather than stopping at the first one, which remains + deterministic for a given tree. Neither choice changes scope or command + intent. + +- [x] T03: `Add architecture-check fixtures and test script` (status:done) + - Task ID: T03 + - Goal: Add `scripts/test-check-cli-architecture.sh`, following the existing + `scripts/test-check-generated.sh` pattern (temp-dir fixture repo, no + modification of real source), that proves the check rejects: a domain + file importing `crate::adapters`, a domain file using `std::fs`, an + application file importing `crate::services`, and an application file + importing `turso`; and accepts: domain code using `std::path::PathBuf`, + application code importing `crate::domain`, adapter code importing + `crate::application`, and composition code delegating to the legacy `app` + module. + - Boundaries (in/out of scope): In — the test script and its fixtures. Out — + modifying `scripts/check-cli-architecture.sh` behavior itself except to + fix any incorrect diagnostic found while writing the tests; out — Nix + wiring (T04). + - Dependencies: T02 + - Done when: `scripts/test-check-cli-architecture.sh` exits `0` and its + output shows all eight required assertions (4 reject, 4 accept) passing. + - Verification notes (commands or checks): `scripts/test-check-cli-architecture.sh` + - Implementation evidence: Added `scripts/test-check-cli-architecture.sh` + (executable), mirroring `scripts/test-check-generated.sh`'s conventions + (`mktemp -d` root with a `trap` cleanup, no modification of real source). + It builds 8 isolated fixture trees under the temp root — one per required + assertion — and drives `scripts/check-cli-architecture.sh` against each via + `CLI_ARCH_CHECK_ROOT`, using `assert_reject` (non-zero exit plus an + expected offending-file/token substring in output) and `assert_accept` + (zero exit) helpers. Reject fixtures: domain file importing + `crate::adapters`; domain file using `std::fs`; application file importing + `crate::services`; application file importing `turso`. Accept fixtures: + domain file using `std::path::PathBuf`; application file importing + `crate::domain`; adapter file importing `crate::application`; composition + file delegating to `crate::app`. The script tallies pass/fail counts and + exits non-zero if any assertion fails. + - Verification commands and outcomes: `./scripts/test-check-cli-architecture.sh` + (all 8 assertions printed `PASS`, script exited 0 with summary + "all 8 assertions passed"); `./scripts/check-cli-architecture.sh` against + the real tree (unaffected, still exits 0 with its pass message). + - Deviations/assumptions: No incorrect diagnostic surfaced while writing the + tests, so `scripts/check-cli-architecture.sh` required no behavior change. + Adapter and composition fixtures are included even though the check script + does not scan those directories, satisfying the plan's four required + accept assertions explicitly. + +- [x] T04: `Wire the architecture check into root Nix checks` (status:done) + - Task ID: T04 + - Goal: Add a new `runCommand`-based check derivation in `flake.nix` + (following the existing `workflow-actionlint` pattern) that runs + `scripts/check-cli-architecture.sh` against the repository tree and + `scripts/test-check-cli-architecture.sh`, and register it under the root + `checks.` attribute set so `nix flake check` exercises it. + - Boundaries (in/out of scope): In — one new check derivation and its + registration in `flake.nix`. Out — any change to existing check + derivations, devShells, or packages. + - Dependencies: T03 + - Done when: `nix flake check` includes and passes the new check, and + `nix flake check --print-build-logs 2>&1 | grep -i "cli-architecture"` shows + it ran. + - Verification notes (commands or checks): `nix flake check` + - Implementation evidence: Added `cliArchitectureCheckSrc` + (`pkgs.lib.fileset.toSource`, rooted at `workspaceRoot`) covering + `scripts/check-cli-architecture.sh`, `scripts/test-check-cli-architecture.sh`, + `cli/src/domain`, and `cli/src/application`. Added `cliArchitectureCheck` + (`pkgs.runCommand "cli-architecture-check"`) that copies that source into + a writable `./repo`, `patchShebangs ./repo/scripts` (the sandbox has no + `/usr/bin/env`, matching the existing `pklGeneratedCheck` pattern), then + runs `bash ./scripts/check-cli-architecture.sh` and + `bash ./scripts/test-check-cli-architecture.sh` before `mkdir -p "$out"`. + Registered it as `cli-architecture = cliArchitectureCheck;` in the root + `checks` attribute set alongside `workflow-actionlint` and + `native-portability-audit`. + - Verification commands and outcomes: `nix flake check --print-build-logs` + (all checks passed, including `checks.x86_64-linux.cli-architecture`); + `nix flake check --print-build-logs 2>&1 | grep -i "cli-architecture"` + (showed `checking derivation checks.x86_64-linux.cli-architecture...` and + the evaluated derivation path). + - Deviations/assumptions: Nix flake evaluation only sees git-tracked (or + staged) files, so the previously untracked T01–T03 deliverables + (`cli/src/{domain,application,adapters}/`, `cli/src/composition.rs`, + `scripts/check-cli-architecture.sh`, `scripts/test-check-cli-architecture.sh`) + and this task's `flake.nix`/`cli/src/main.rs` changes were staged with + `git add` (no commit) so `nix flake check` could see them; this is a + prerequisite for running the required verification, not a scope change. + +- [x] T05: `Document the internal hexagonal architecture in context/architecture.md` (status:done) + - Task ID: T05 + - Goal: Add a new `## CLI internal hexagonal architecture` section to + `context/architecture.md` covering: the CLI stays one Cargo package; + hexagonal architecture here is about dependency direction, not crate + count; what each of `domain`, `application`, `adapters/inbound`, + `adapters/outbound`, and `composition` owns; that `services` is a + temporary compatibility namespace that new domain/application code must + never depend on; that migration proceeds through vertical slices rather + than a big-bang rewrite; the dependency diagram from the change request; + and that adapter implementations depend inward on application-owned + ports. + - Boundaries (in/out of scope): In — `context/architecture.md` only. Out — + any other `context/**` file (leave `context/cli/cli-command-surface.md` + etc. as a follow-up if it turns out to need updating; check it, only edit + if a specific claim there would now be misleading). + - Dependencies: T04 + - Done when: `grep -n "Hexagonal" context/architecture.md` and + `grep -n "services" context/architecture.md` both match, and the section + covers all ten points listed in the change request. + - Verification notes (commands or checks): `grep -n "Hexagonal" context/architecture.md`; `grep -n "services" context/architecture.md`; manual read-through against the ten required points. + - Implementation evidence: Inserted a new `## CLI internal hexagonal + architecture` section into `context/architecture.md` (after the + `## Placeholder SCE CLI boundary` section, before + `## CLI install/distribution boundary`) covering: single-Cargo-package + framing and hexagonal-as-dependency-direction-not-crate-count; per-layer + ownership for `domain`, `application` (`error.rs`/`ports/`/`use_cases/`), + `adapters/inbound` (`cli/`), `adapters/outbound`, and `composition` + (`composition::run` as `main.rs`'s sole entrypoint, currently delegating + to `app::run`); the adapters-depend-inward-on-application-owned-ports + rule; `services` as a temporary compatibility namespace domain/application + code must never depend on, expected to shrink over time; the + vertical-slice migration strategy versus a big-bang rewrite; a Mermaid + dependency-direction diagram (adapters -> application -> domain, + composition -> adapters/services, adapters -.transitional.-> services); + and a closing paragraph describing `scripts/check-cli-architecture.sh`'s + enforced forbidden-import lists and its `nix flake check` wiring. + `context/cli/cli-command-surface.md` was reviewed and left unedited: it + documents command-surface behavior, not the new internal module + boundaries, so no claim there is now misleading. + - Verification commands and outcomes: `grep -n "Hexagonal" + context/architecture.md` (matched, line 87); `grep -n "services" + context/architecture.md` (multiple matches including the new section); + manual read-through of the new section against the ten required points + (all present). + - Deviations/assumptions: None. + +## Open questions + +None. The change request is a fully specified task with explicit target file +layout, dependency rules, forbidden-import lists, acceptance criteria with +proof commands, and an explicit non-goals list; there is no unresolved scope, +architecture, or ordering decision left to make. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-04 + +### Commands run + +- `nix flake check --print-build-logs` -> exit 0 (all checks passed, including `checks.x86_64-linux.cli-architecture`) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (183 passed, 0 failed) +- `./scripts/check-cli-architecture.sh` -> exit 0 (pass message against real tree) +- `./scripts/test-check-cli-architecture.sh` -> exit 0 (all 8 assertions passed) +- `test -f cli/src/domain/mod.rs && test -f cli/src/application/mod.rs && test -f cli/src/adapters/mod.rs && test -f cli/src/composition.rs` -> exit 0 +- `grep -n "composition::run" cli/src/main.rs` -> exit 0 (matched line 17) +- `grep -n "Hexagonal" context/architecture.md` -> exit 0 (matched) +- `grep -n "services" context/architecture.md` -> exit 0 (multiple matches) + +### Scaffolding removed + +- None. Fixtures used by `scripts/test-check-cli-architecture.sh` are generated at runtime via `mktemp -d` and cleaned up by its own trap; no committed temporary files remain. + +### Success-criteria verification + +- [x] AC1: Four internal module files exist -> `test -f` checks all passed. +- [x] AC2: `main.rs` calls `composition::run` -> matched at `cli/src/main.rs:17`; `cargo test` passed (183/183). +- [x] AC3: `context/architecture.md` documents the four layers, dependency direction, `services` role, migration strategy -> both greps matched; new `## CLI internal hexagonal architecture` section present. +- [x] AC4: Architecture check fails on forbidden domain imports -> `check-cli-architecture.sh` passes real tree; `test-check-cli-architecture.sh` domain-reject fixtures (crate::adapters, std::fs) both PASS. +- [x] AC5: Architecture check fails on forbidden application imports -> `test-check-cli-architecture.sh` application-reject fixtures (crate::services, turso) both PASS. +- [x] AC6: Check accepts required positive cases -> `test-check-cli-architecture.sh` accept fixtures (domain PathBuf, application->domain, adapter->application, composition->app) all PASS. +- [x] AC7: Existing tests, formatting, linting, generated-asset pipeline pass unchanged -> `nix flake check` exit 0, all checks including `cli-fmt`, `cli-generated-input`, `pkl-generated`. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. diff --git a/flake.nix b/flake.nix index f7d7cc31..8262b8dc 100644 --- a/flake.nix +++ b/flake.nix @@ -196,6 +196,16 @@ ]; }; + cliArchitectureCheckSrc = pkgs.lib.fileset.toSource { + root = workspaceRoot; + fileset = pkgs.lib.fileset.unions [ + ./scripts/check-cli-architecture.sh + ./scripts/test-check-cli-architecture.sh + ./cli/src/domain + ./cli/src/application + ]; + }; + flatpakPackagingSrc = pkgs.lib.fileset.toSource { root = workspaceRoot; fileset = pkgs.lib.fileset.unions [ @@ -1341,6 +1351,23 @@ mkdir -p "$out" ''; + cliArchitectureCheck = + pkgs.runCommand "cli-architecture-check" + { } + '' + set -euo pipefail + + cp -r "${cliArchitectureCheckSrc}" ./repo + chmod -R u+w ./repo + patchShebangs ./repo/scripts + cd ./repo + + bash ./scripts/check-cli-architecture.sh + bash ./scripts/test-check-cli-architecture.sh + + mkdir -p "$out" + ''; + nativePortabilityAuditCheck = pkgs.runCommand "native-portability-audit-check" { } @@ -1529,6 +1556,7 @@ workflow-actionlint = workflowActionlintCheck; native-portability-audit = nativePortabilityAuditCheck; + cli-architecture = cliArchitectureCheck; } // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { flatpak-static-validation = flatpakStaticValidationCheck; diff --git a/scripts/check-cli-architecture.sh b/scripts/check-cli-architecture.sh new file mode 100755 index 00000000..8090c6d1 --- /dev/null +++ b/scripts/check-cli-architecture.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="${CLI_ARCH_CHECK_ROOT:-$(cd "${script_dir}/.." && pwd)}" + +domain_root="${repo_root}/cli/src/domain" +application_root="${repo_root}/cli/src/application" + +domain_forbidden=( + clap + turso + reqwest + inquire + keyring_core + std::fs + std::env + std::process + crate::adapters + crate::application + crate::composition + crate::services +) + +application_forbidden=( + clap + turso + reqwest + inquire + keyring_core + std::fs + std::process + crate::adapters + crate::composition + crate::services +) + +violations_found=0 + +check_layer() { + local layer_name="$1" + local layer_root="$2" + shift 2 + local forbidden=("$@") + + if [ ! -d "${layer_root}" ]; then + return 0 + fi + + local file + while IFS= read -r -d '' file; do + local line_num=0 + local line + while IFS= read -r line || [ -n "${line}" ]; do + line_num=$((line_num + 1)) + + # Best-effort: skip lines that are entirely a comment. + if [[ "${line}" =~ ^[[:space:]]*// ]]; then + continue + fi + + local token + for token in "${forbidden[@]}"; do + local pattern="(^|[^A-Za-z0-9_:])${token}([^A-Za-z0-9_]|\$)" + if [[ "${line}" =~ ${pattern} ]]; then + printf '%s:%d: forbidden dependency in %s layer: %s\n' \ + "${file}" "${line_num}" "${layer_name}" "${token}" >&2 + violations_found=1 + fi + done + done < "${file}" + done < <(find "${layer_root}" -type f -name '*.rs' -print0) +} + +check_layer domain "${domain_root}" "${domain_forbidden[@]}" +check_layer application "${application_root}" "${application_forbidden[@]}" + +if [ "${violations_found}" -ne 0 ]; then + exit 1 +fi + +printf 'cli-architecture check passed: no forbidden dependencies in domain or application layers.\n' diff --git a/scripts/test-check-cli-architecture.sh b/scripts/test-check-cli-architecture.sh new file mode 100755 index 00000000..90ba5661 --- /dev/null +++ b/scripts/test-check-cli-architecture.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +check_script="${script_dir}/check-cli-architecture.sh" + +tmp_root="$(mktemp -d)" +cleanup() { + rm -rf "${tmp_root}" +} +trap cleanup EXIT + +assertions=0 +failures=0 + +run_check() { + local root="$1" + CLI_ARCH_CHECK_ROOT="${root}" "${check_script}" +} + +assert_reject() { + local name="$1" root="$2" expected_substring="$3" + local output="" + local status=0 + output="$(run_check "${root}" 2>&1)" || status=$? + assertions=$((assertions + 1)) + + if [ "${status}" -eq 0 ]; then + printf 'FAIL (%s): expected non-zero exit, got 0. Output:\n%s\n' \ + "${name}" "${output}" >&2 + failures=$((failures + 1)) + return + fi + if [[ "${output}" != *"${expected_substring}"* ]]; then + printf 'FAIL (%s): expected output to contain %q. Output:\n%s\n' \ + "${name}" "${expected_substring}" "${output}" >&2 + failures=$((failures + 1)) + return + fi + printf 'PASS (%s)\n' "${name}" +} + +assert_accept() { + local name="$1" root="$2" + local output="" + local status=0 + output="$(run_check "${root}" 2>&1)" || status=$? + assertions=$((assertions + 1)) + + if [ "${status}" -ne 0 ]; then + printf 'FAIL (%s): expected zero exit, got %d. Output:\n%s\n' \ + "${name}" "${status}" "${output}" >&2 + failures=$((failures + 1)) + return + fi + printf 'PASS (%s)\n' "${name}" +} + +# --- Reject fixtures --- + +reject_domain_adapters="${tmp_root}/reject-domain-adapters" +mkdir -p "${reject_domain_adapters}/cli/src/domain" +printf '//! domain layer\nuse crate::adapters;\n' \ + > "${reject_domain_adapters}/cli/src/domain/mod.rs" +assert_reject 'domain importing crate::adapters' "${reject_domain_adapters}" \ + 'forbidden dependency in domain layer: crate::adapters' + +reject_domain_stdfs="${tmp_root}/reject-domain-stdfs" +mkdir -p "${reject_domain_stdfs}/cli/src/domain" +printf '//! domain layer\nuse std::fs;\n' \ + > "${reject_domain_stdfs}/cli/src/domain/mod.rs" +assert_reject 'domain using std::fs' "${reject_domain_stdfs}" \ + 'forbidden dependency in domain layer: std::fs' + +reject_application_services="${tmp_root}/reject-application-services" +mkdir -p "${reject_application_services}/cli/src/application" +printf '//! application layer\nuse crate::services;\n' \ + > "${reject_application_services}/cli/src/application/mod.rs" +assert_reject 'application importing crate::services' "${reject_application_services}" \ + 'forbidden dependency in application layer: crate::services' + +reject_application_turso="${tmp_root}/reject-application-turso" +mkdir -p "${reject_application_turso}/cli/src/application" +printf '//! application layer\nuse turso::Connection;\n' \ + > "${reject_application_turso}/cli/src/application/mod.rs" +assert_reject 'application importing turso' "${reject_application_turso}" \ + 'forbidden dependency in application layer: turso' + +# --- Accept fixtures --- + +accept_domain_pathbuf="${tmp_root}/accept-domain-pathbuf" +mkdir -p "${accept_domain_pathbuf}/cli/src/domain" +printf '//! domain layer\nuse std::path::PathBuf;\n\npub struct Example(PathBuf);\n' \ + > "${accept_domain_pathbuf}/cli/src/domain/mod.rs" +assert_accept 'domain using std::path::PathBuf' "${accept_domain_pathbuf}" + +accept_application_domain="${tmp_root}/accept-application-domain" +mkdir -p "${accept_application_domain}/cli/src/application" +printf '//! application layer\nuse crate::domain;\n' \ + > "${accept_application_domain}/cli/src/application/mod.rs" +assert_accept 'application importing crate::domain' "${accept_application_domain}" + +accept_adapter_application="${tmp_root}/accept-adapter-application" +mkdir -p "${accept_adapter_application}/cli/src/adapters" +printf '//! adapters layer\nuse crate::application;\n' \ + > "${accept_adapter_application}/cli/src/adapters/mod.rs" +assert_accept 'adapter importing crate::application' "${accept_adapter_application}" + +accept_composition_app="${tmp_root}/accept-composition-app" +mkdir -p "${accept_composition_app}/cli/src" +printf '//! composition root\nuse crate::app;\n\npub(crate) fn run() {\n crate::app::run(std::env::args());\n}\n' \ + > "${accept_composition_app}/cli/src/composition.rs" +assert_accept 'composition delegating to crate::app' "${accept_composition_app}" + +if [ "${failures}" -ne 0 ]; then + printf '%d of %d assertions failed.\n' "${failures}" "${assertions}" >&2 + exit 1 +fi + +printf 'test-check-cli-architecture: all %d assertions passed.\n' "${assertions}" From 0e8d28b3c027abd46e0ee902a130491a41441ac0 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 4 Aug 2026 01:14:29 +0200 Subject: [PATCH 2/5] cli: Migrate context baseline bootstrap to hexagonal vertical slice `services::setup::bootstrap_context_baseline` mixed the canonical directory/file manifest, RepoPaths-based path calculation, direct std::fs I/O, and styled success-string rendering into one function, leaving the hexagonal skeleton from a prior commit with no proven vertical slice through it. Carve the operation into domain/application/adapter layers: `ContextBaseline` (cli/src/domain/context/baseline.rs) defines the manifest; `ContextStore` (cli/src/application/ports/context_store.rs) and `EnsureContextBaseline` (cli/src/application/use_cases/ensure_context_baseline.rs) define the port and use case; `FilesystemContextStore` (cli/src/adapters/outbound/filesystem/context_store.rs) performs the actual I/O; `render_context_baseline_report` (cli/src/adapters/inbound/cli/setup.rs) renders the report. `bootstrap_context_baseline` becomes a thin compatibility facade over these layers, so both `sce setup --bootstrap-context` and every normal successful `sce setup` run pick up the new implementation with unchanged output. The now-unused `RepoPaths::context_*` accessors and legacy template constants are removed. Co-authored-by: SCE --- cli/src/adapters/inbound/cli/mod.rs | 2 + cli/src/adapters/inbound/cli/setup.rs | 10 + cli/src/adapters/inbound/mod.rs | 2 +- cli/src/adapters/mod.rs | 4 +- .../outbound/filesystem/context_store.rs | 207 ++++++++ cli/src/adapters/outbound/filesystem/mod.rs | 3 + cli/src/adapters/outbound/mod.rs | 2 + cli/src/application/mod.rs | 4 +- cli/src/application/ports/context_store.rs | 30 ++ cli/src/application/ports/mod.rs | 2 + .../use_cases/ensure_context_baseline.rs | 96 ++++ cli/src/application/use_cases/mod.rs | 2 + cli/src/domain/context/baseline.rs | 148 ++++++ cli/src/domain/context/mod.rs | 3 + cli/src/domain/mod.rs | 2 + cli/src/services/default_paths.rs | 58 --- cli/src/services/setup/mod.rs | 145 ++---- context/architecture.md | 21 +- context/cli/default-path-catalog.md | 2 +- context/glossary.md | 6 +- ...migrate-context-baseline-vertical-slice.md | 479 ++++++++++++++++++ .../sce/setup-repo-local-config-bootstrap.md | 2 +- 22 files changed, 1064 insertions(+), 166 deletions(-) create mode 100644 cli/src/adapters/inbound/cli/setup.rs create mode 100644 cli/src/adapters/outbound/filesystem/context_store.rs create mode 100644 cli/src/adapters/outbound/filesystem/mod.rs create mode 100644 cli/src/application/ports/context_store.rs create mode 100644 cli/src/application/use_cases/ensure_context_baseline.rs create mode 100644 cli/src/domain/context/baseline.rs create mode 100644 cli/src/domain/context/mod.rs create mode 100644 context/plans/migrate-context-baseline-vertical-slice.md diff --git a/cli/src/adapters/inbound/cli/mod.rs b/cli/src/adapters/inbound/cli/mod.rs index 8d2e52cc..38fcdb13 100644 --- a/cli/src/adapters/inbound/cli/mod.rs +++ b/cli/src/adapters/inbound/cli/mod.rs @@ -1 +1,3 @@ //! CLI inbound adapter: parses command-line input and invokes use cases. + +pub(crate) mod setup; diff --git a/cli/src/adapters/inbound/cli/setup.rs b/cli/src/adapters/inbound/cli/setup.rs new file mode 100644 index 00000000..f82c85d9 --- /dev/null +++ b/cli/src/adapters/inbound/cli/setup.rs @@ -0,0 +1,10 @@ +//! Inbound CLI rendering for the `setup` command's use-case reports. + +use crate::application::use_cases::ensure_context_baseline::EnsureContextBaselineReport; +use crate::services::style::success; + +/// Renders the result of `EnsureContextBaseline::execute` as the CLI's +/// existing "Context baseline ensured." success message. +pub(crate) fn render_context_baseline_report(_report: &EnsureContextBaselineReport) -> String { + success("Context baseline ensured.") +} diff --git a/cli/src/adapters/inbound/mod.rs b/cli/src/adapters/inbound/mod.rs index 1d875028..9b3505a1 100644 --- a/cli/src/adapters/inbound/mod.rs +++ b/cli/src/adapters/inbound/mod.rs @@ -1,3 +1,3 @@ //! Inbound adapters: entrypoints that drive the application layer. -mod cli; +pub(crate) mod cli; diff --git a/cli/src/adapters/mod.rs b/cli/src/adapters/mod.rs index 3489e121..5ec0687d 100644 --- a/cli/src/adapters/mod.rs +++ b/cli/src/adapters/mod.rs @@ -4,5 +4,5 @@ //! `crate::services`. See `context/architecture.md` for the full //! dependency-direction rules. -mod inbound; -mod outbound; +pub(crate) mod inbound; +pub(crate) mod outbound; diff --git a/cli/src/adapters/outbound/filesystem/context_store.rs b/cli/src/adapters/outbound/filesystem/context_store.rs new file mode 100644 index 00000000..4223b177 --- /dev/null +++ b/cli/src/adapters/outbound/filesystem/context_store.rs @@ -0,0 +1,207 @@ +//! `FilesystemContextStore`: the `ContextStore` outbound adapter that +//! persists the durable-context baseline to disk. + +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::application::ports::context_store::{ContextBaselineChanges, ContextStore}; +use crate::domain::context::baseline::ContextBaseline; + +/// A filesystem operation failed for a specific baseline path. +#[derive(Debug)] +pub(crate) struct ContextStoreError { + pub(crate) path: PathBuf, + pub(crate) source: std::io::Error, +} + +impl fmt::Display for ContextStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "failed to write context baseline path '{}': {}", + self.path.display(), + self.source + ) + } +} + +impl std::error::Error for ContextStoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +/// Persists the durable-context baseline directly to the filesystem. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct FilesystemContextStore; + +impl ContextStore for FilesystemContextStore { + type Error = ContextStoreError; + + fn ensure_baseline( + &self, + repository_root: &Path, + baseline: &ContextBaseline, + ) -> Result { + let mut changes = ContextBaselineChanges::default(); + + for relative_directory in &baseline.directories { + let directory = repository_root.join(relative_directory); + if directory.exists() { + changes.existing_directories.push(directory); + } else { + create_dir_all(&directory)?; + changes.created_directories.push(directory); + } + } + + for file in &baseline.files { + let path = repository_root.join(&file.relative_path); + if path.exists() { + changes.existing_files.push(path); + continue; + } + + if let Some(parent) = path.parent() { + create_dir_all(parent)?; + } + + fs::write(&path, file.initial_content).map_err(|source| ContextStoreError { + path: path.clone(), + source, + })?; + changes.created_files.push(path); + } + + Ok(changes) + } +} + +fn create_dir_all(path: &Path) -> Result<(), ContextStoreError> { + fs::create_dir_all(path).map_err(|source| ContextStoreError { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-filesystem-context-store-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + #[test] + fn ensure_baseline_creates_a_missing_baseline_fully() { + let repo = unique_temp_dir("full"); + let baseline = ContextBaseline::sce_default(); + let store = FilesystemContextStore; + + let changes = store + .ensure_baseline(&repo, &baseline) + .expect("ensure baseline"); + + assert_eq!( + changes.created_directories.len(), + baseline.directories.len() + ); + assert!(changes.existing_directories.is_empty()); + assert_eq!(changes.created_files.len(), baseline.files.len()); + assert!(changes.existing_files.is_empty()); + + for directory in &baseline.directories { + assert!(repo.join(directory).is_dir()); + } + for file in &baseline.files { + let content = fs::read_to_string(repo.join(&file.relative_path)).expect("read file"); + assert_eq!(content, file.initial_content); + } + } + + #[test] + fn ensure_baseline_leaves_existing_custom_content_byte_for_byte_unchanged() { + let repo = unique_temp_dir("idempotent"); + let baseline = ContextBaseline::sce_default(); + let store = FilesystemContextStore; + + store + .ensure_baseline(&repo, &baseline) + .expect("initial bootstrap"); + + let overview_path = repo.join("context/overview.md"); + let sentinel = "SENTINEL_OVERVIEW_CONTENT\n"; + fs::write(&overview_path, sentinel).expect("seed sentinel"); + + store + .ensure_baseline(&repo, &baseline) + .expect("rerun bootstrap"); + + let content = fs::read_to_string(&overview_path).expect("read overview"); + assert_eq!(content, sentinel); + } + + #[test] + fn ensure_baseline_second_run_reports_only_existing_paths() { + let repo = unique_temp_dir("second-run"); + let baseline = ContextBaseline::sce_default(); + let store = FilesystemContextStore; + + store + .ensure_baseline(&repo, &baseline) + .expect("initial bootstrap"); + + let changes = store + .ensure_baseline(&repo, &baseline) + .expect("second bootstrap"); + + assert!(changes.created_directories.is_empty()); + assert!(changes.created_files.is_empty()); + assert_eq!( + changes.existing_directories.len(), + baseline.directories.len() + ); + assert_eq!(changes.existing_files.len(), baseline.files.len()); + } + + #[test] + fn ensure_baseline_creates_only_missing_paths_in_a_partially_bootstrapped_tree() { + let repo = unique_temp_dir("partial"); + let baseline = ContextBaseline::sce_default(); + let store = FilesystemContextStore; + + fs::create_dir_all(repo.join("context")).expect("seed context dir"); + fs::write(repo.join("context/overview.md"), "existing\n").expect("seed overview file"); + + let changes = store + .ensure_baseline(&repo, &baseline) + .expect("partial bootstrap"); + + assert_eq!(changes.existing_directories, vec![repo.join("context")]); + assert_eq!( + changes.created_directories.len(), + baseline.directories.len() - 1 + ); + assert_eq!( + changes.existing_files, + vec![repo.join("context/overview.md")] + ); + assert_eq!(changes.created_files.len(), baseline.files.len() - 1); + + let overview_content = + fs::read_to_string(repo.join("context/overview.md")).expect("read overview"); + assert_eq!(overview_content, "existing\n"); + } +} diff --git a/cli/src/adapters/outbound/filesystem/mod.rs b/cli/src/adapters/outbound/filesystem/mod.rs new file mode 100644 index 00000000..50a2a568 --- /dev/null +++ b/cli/src/adapters/outbound/filesystem/mod.rs @@ -0,0 +1,3 @@ +//! Filesystem-backed outbound adapters. + +pub(crate) mod context_store; diff --git a/cli/src/adapters/outbound/mod.rs b/cli/src/adapters/outbound/mod.rs index 9048de3f..e803d8ec 100644 --- a/cli/src/adapters/outbound/mod.rs +++ b/cli/src/adapters/outbound/mod.rs @@ -1,2 +1,4 @@ //! Outbound adapters: implementations of application ports (storage, network, //! and other infrastructure integrations). + +pub(crate) mod filesystem; diff --git a/cli/src/application/mod.rs b/cli/src/application/mod.rs index 76eea3ad..7ca4b850 100644 --- a/cli/src/application/mod.rs +++ b/cli/src/application/mod.rs @@ -7,5 +7,5 @@ //! rules. mod error; -mod ports; -mod use_cases; +pub(crate) mod ports; +pub(crate) mod use_cases; diff --git a/cli/src/application/ports/context_store.rs b/cli/src/application/ports/context_store.rs new file mode 100644 index 00000000..ec80a504 --- /dev/null +++ b/cli/src/application/ports/context_store.rs @@ -0,0 +1,30 @@ +//! `ContextStore` port: durable-context baseline persistence, owned by an +//! outbound adapter and consumed by the `EnsureContextBaseline` use case. + +use std::path::{Path, PathBuf}; + +use crate::domain::context::baseline::ContextBaseline; + +/// The directories and files a baseline-ensure operation created versus +/// found already present. +#[derive(Clone, Debug, Eq, PartialEq, Default)] +#[allow(dead_code)] // consumed starting with the EnsureContextBaseline use case (T03) +pub(crate) struct ContextBaselineChanges { + pub(crate) created_directories: Vec, + pub(crate) existing_directories: Vec, + pub(crate) created_files: Vec, + pub(crate) existing_files: Vec, +} + +/// Persists the durable-context baseline additively against a repository +/// root. +#[allow(dead_code)] // consumed starting with the EnsureContextBaseline use case (T03) +pub(crate) trait ContextStore { + type Error; + + fn ensure_baseline( + &self, + repository_root: &Path, + baseline: &ContextBaseline, + ) -> Result; +} diff --git a/cli/src/application/ports/mod.rs b/cli/src/application/ports/mod.rs index 975e42dc..f2da15e2 100644 --- a/cli/src/application/ports/mod.rs +++ b/cli/src/application/ports/mod.rs @@ -1 +1,3 @@ //! Ports: interfaces the application layer depends on and adapters implement. + +pub(crate) mod context_store; diff --git a/cli/src/application/use_cases/ensure_context_baseline.rs b/cli/src/application/use_cases/ensure_context_baseline.rs new file mode 100644 index 00000000..8dfdf83b --- /dev/null +++ b/cli/src/application/use_cases/ensure_context_baseline.rs @@ -0,0 +1,96 @@ +//! `EnsureContextBaseline` use case: ensures the durable-context baseline +//! exists in a repository, delegating persistence to an injected +//! `ContextStore`. + +use std::path::PathBuf; + +use crate::application::ports::context_store::{ContextBaselineChanges, ContextStore}; +use crate::domain::context::baseline::ContextBaseline; + +/// The repository root to ensure the durable-context baseline against. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EnsureContextBaselineRequest { + pub(crate) repository_root: PathBuf, +} + +/// The outcome of ensuring the durable-context baseline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EnsureContextBaselineReport { + pub(crate) repository_root: PathBuf, + pub(crate) changes: ContextBaselineChanges, +} + +/// Ensures the SCE-canonical durable-context baseline exists in a +/// repository, via an injected `ContextStore`. +pub(crate) struct EnsureContextBaseline { + store: S, +} + +impl EnsureContextBaseline { + pub(crate) fn new(store: S) -> Self { + Self { store } + } + + pub(crate) fn execute( + &self, + request: EnsureContextBaselineRequest, + ) -> Result { + let baseline = ContextBaseline::sce_default(); + let changes = self + .store + .ensure_baseline(&request.repository_root, &baseline)?; + + Ok(EnsureContextBaselineReport { + repository_root: request.repository_root, + changes, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::path::Path; + + #[derive(Default)] + struct FakeContextStore { + calls: RefCell>, + } + + impl ContextStore for FakeContextStore { + type Error = (); + + fn ensure_baseline( + &self, + repository_root: &Path, + baseline: &ContextBaseline, + ) -> Result { + self.calls + .borrow_mut() + .push((repository_root.to_path_buf(), baseline.clone())); + Ok(ContextBaselineChanges::default()) + } + } + + #[test] + fn execute_calls_ensure_baseline_with_resolved_root_and_default_baseline() { + let store = FakeContextStore::default(); + let use_case = EnsureContextBaseline::new(store); + let repository_root = PathBuf::from("/repo"); + + let report = use_case + .execute(EnsureContextBaselineRequest { + repository_root: repository_root.clone(), + }) + .unwrap(); + + assert_eq!(report.repository_root, repository_root); + assert_eq!(report.changes, ContextBaselineChanges::default()); + + let calls = use_case.store.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, repository_root); + assert_eq!(calls[0].1, ContextBaseline::sce_default()); + } +} diff --git a/cli/src/application/use_cases/mod.rs b/cli/src/application/use_cases/mod.rs index 477c6cd4..5871a398 100644 --- a/cli/src/application/use_cases/mod.rs +++ b/cli/src/application/use_cases/mod.rs @@ -1 +1,3 @@ //! Use cases: application-specific orchestration of domain and ports. + +pub(crate) mod ensure_context_baseline; diff --git a/cli/src/domain/context/baseline.rs b/cli/src/domain/context/baseline.rs new file mode 100644 index 00000000..5f1561af --- /dev/null +++ b/cli/src/domain/context/baseline.rs @@ -0,0 +1,148 @@ +//! The canonical durable-context baseline: the directory/file manifest +//! `sce setup` bootstraps additively into a repository. + +use std::path::PathBuf; + +/// A baseline file within the context tree: its repository-relative path +/// and the content it is created with when the file does not yet exist. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct BaselineFile { + pub(crate) relative_path: PathBuf, + pub(crate) initial_content: &'static str, +} + +/// The canonical set of durable-context directories and files. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ContextBaseline { + pub(crate) directories: Vec, + pub(crate) files: Vec, +} + +const CONTEXT_TMP_GITIGNORE_CONTENT: &str = "*\n!.gitignore\n"; + +const CONTEXT_OVERVIEW_TEMPLATE: &str = "# Overview\n\n"; +const CONTEXT_ARCHITECTURE_TEMPLATE: &str = "# Architecture\n\n"; +const CONTEXT_PATTERNS_TEMPLATE: &str = "# Patterns\n\n"; +const CONTEXT_GLOSSARY_TEMPLATE: &str = "# Glossary\n\n"; +const CONTEXT_MAP_TEMPLATE: &str = "\ +# Context Map + +Primary context files: + +- `context/overview.md` +- `context/architecture.md` +- `context/patterns.md` +- `context/glossary.md` + +Working areas: + +- `context/plans/` +- `context/handovers/` +- `context/decisions/` +- `context/tmp/` +"; + +impl ContextBaseline { + /// The SCE-canonical durable-context baseline. + pub(crate) fn sce_default() -> Self { + Self { + directories: vec![ + PathBuf::from("context"), + PathBuf::from("context/plans"), + PathBuf::from("context/handovers"), + PathBuf::from("context/decisions"), + PathBuf::from("context/tmp"), + ], + files: vec![ + BaselineFile { + relative_path: PathBuf::from("context/overview.md"), + initial_content: CONTEXT_OVERVIEW_TEMPLATE, + }, + BaselineFile { + relative_path: PathBuf::from("context/architecture.md"), + initial_content: CONTEXT_ARCHITECTURE_TEMPLATE, + }, + BaselineFile { + relative_path: PathBuf::from("context/patterns.md"), + initial_content: CONTEXT_PATTERNS_TEMPLATE, + }, + BaselineFile { + relative_path: PathBuf::from("context/glossary.md"), + initial_content: CONTEXT_GLOSSARY_TEMPLATE, + }, + BaselineFile { + relative_path: PathBuf::from("context/context-map.md"), + initial_content: CONTEXT_MAP_TEMPLATE, + }, + BaselineFile { + relative_path: PathBuf::from("context/tmp/.gitignore"), + initial_content: CONTEXT_TMP_GITIGNORE_CONTENT, + }, + ], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sce_default_has_the_canonical_directories_and_files() { + let baseline = ContextBaseline::sce_default(); + + let directories: Vec<&str> = baseline + .directories + .iter() + .map(|path| path.to_str().unwrap()) + .collect(); + assert_eq!( + directories, + vec![ + "context", + "context/plans", + "context/handovers", + "context/decisions", + "context/tmp", + ] + ); + + let files: Vec<&str> = baseline + .files + .iter() + .map(|file| file.relative_path.to_str().unwrap()) + .collect(); + assert_eq!( + files, + vec![ + "context/overview.md", + "context/architecture.md", + "context/patterns.md", + "context/glossary.md", + "context/context-map.md", + "context/tmp/.gitignore", + ] + ); + } + + #[test] + fn sce_default_file_content_matches_legacy_templates() { + let baseline = ContextBaseline::sce_default(); + + let content_for = |relative_path: &str| { + baseline + .files + .iter() + .find(|file| file.relative_path.to_str().unwrap() == relative_path) + .unwrap() + .initial_content + }; + + assert_eq!(content_for("context/overview.md"), "# Overview\n\n"); + assert_eq!(content_for("context/architecture.md"), "# Architecture\n\n"); + assert_eq!(content_for("context/patterns.md"), "# Patterns\n\n"); + assert_eq!(content_for("context/glossary.md"), "# Glossary\n\n"); + assert_eq!(content_for("context/tmp/.gitignore"), "*\n!.gitignore\n"); + assert!(content_for("context/context-map.md").starts_with("# Context Map")); + } +} diff --git a/cli/src/domain/context/mod.rs b/cli/src/domain/context/mod.rs new file mode 100644 index 00000000..b1b3d6b2 --- /dev/null +++ b/cli/src/domain/context/mod.rs @@ -0,0 +1,3 @@ +//! Durable-context domain model: the baseline directory/file manifest. + +pub(crate) mod baseline; diff --git a/cli/src/domain/mod.rs b/cli/src/domain/mod.rs index a9b2da67..52b10ada 100644 --- a/cli/src/domain/mod.rs +++ b/cli/src/domain/mod.rs @@ -4,3 +4,5 @@ //! `crate::composition`, `crate::services`, or any infrastructure crate //! (CLI parsing, database, HTTP, process/filesystem/env access). See //! `context/architecture.md` for the full dependency-direction rules. + +pub(crate) mod context; diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index e03cb544..351b95e7 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -393,21 +393,7 @@ pub(crate) mod pi_asset { pub const EXTENSIONS_DIR: &str = "extensions"; } -pub(crate) mod context_dir { - pub const CONTEXT_ROOT: &str = "context"; - pub const PLANS: &str = "plans"; - pub const DECISIONS: &str = "decisions"; - pub const HANDOVERS: &str = "handovers"; - pub const TMP: &str = "tmp"; -} - pub(crate) mod context_file { - pub const OVERVIEW: &str = "overview.md"; - pub const ARCHITECTURE: &str = "architecture.md"; - pub const GLOSSARY: &str = "glossary.md"; - pub const PATTERNS: &str = "patterns.md"; - pub const CONTEXT_MAP: &str = "context-map.md"; - pub const TMP_GITIGNORE: &str = ".gitignore"; pub const SKILL_DEFINITION: &str = "SKILL.md"; } @@ -466,50 +452,6 @@ impl RepoPaths { pub(crate) fn git_commit_editmsg(&self) -> PathBuf { self.git_dir().join(repo_file::GIT_COMMIT_EDITMSG) } - - pub(crate) fn context_dir(&self) -> PathBuf { - self.root.join(context_dir::CONTEXT_ROOT) - } - - pub(crate) fn context_plans_dir(&self) -> PathBuf { - self.context_dir().join(context_dir::PLANS) - } - - pub(crate) fn context_decisions_dir(&self) -> PathBuf { - self.context_dir().join(context_dir::DECISIONS) - } - - pub(crate) fn context_handovers_dir(&self) -> PathBuf { - self.context_dir().join(context_dir::HANDOVERS) - } - - pub(crate) fn context_tmp_dir(&self) -> PathBuf { - self.context_dir().join(context_dir::TMP) - } - - pub(crate) fn context_overview_file(&self) -> PathBuf { - self.context_dir().join(context_file::OVERVIEW) - } - - pub(crate) fn context_architecture_file(&self) -> PathBuf { - self.context_dir().join(context_file::ARCHITECTURE) - } - - pub(crate) fn context_glossary_file(&self) -> PathBuf { - self.context_dir().join(context_file::GLOSSARY) - } - - pub(crate) fn context_patterns_file(&self) -> PathBuf { - self.context_dir().join(context_file::PATTERNS) - } - - pub(crate) fn context_map_file(&self) -> PathBuf { - self.context_dir().join(context_file::CONTEXT_MAP) - } - - pub(crate) fn context_tmp_gitignore_file(&self) -> PathBuf { - self.context_tmp_dir().join(context_file::TMP_GITIGNORE) - } } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 86c67cb7..16aab0d4 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -437,84 +437,30 @@ pub fn bootstrap_repo_local_config(repository_root: &Path) -> Result<()> { Ok(()) } -const CONTEXT_TMP_GITIGNORE_CONTENT: &str = "*\n!.gitignore\n"; - -const CONTEXT_OVERVIEW_TEMPLATE: &str = "# Overview\n\n"; -const CONTEXT_ARCHITECTURE_TEMPLATE: &str = "# Architecture\n\n"; -const CONTEXT_PATTERNS_TEMPLATE: &str = "# Patterns\n\n"; -const CONTEXT_GLOSSARY_TEMPLATE: &str = "# Glossary\n\n"; -const CONTEXT_MAP_TEMPLATE: &str = "\ -# Context Map - -Primary context files: - -- `context/overview.md` -- `context/architecture.md` -- `context/patterns.md` -- `context/glossary.md` - -Working areas: - -- `context/plans/` -- `context/handovers/` -- `context/decisions/` -- `context/tmp/` -"; - /// Creates the baseline durable-context tree additively. /// /// Missing directories and baseline files are created with neutral templates. /// Existing files and directory contents are never overwritten. pub fn bootstrap_context_baseline(repository_root: &Path) -> Result { - let repo_paths = RepoPaths::new(repository_root); - - ensure_context_directory(&repo_paths.context_dir())?; - ensure_context_directory(&repo_paths.context_plans_dir())?; - ensure_context_directory(&repo_paths.context_handovers_dir())?; - ensure_context_directory(&repo_paths.context_decisions_dir())?; - ensure_context_directory(&repo_paths.context_tmp_dir())?; - - ensure_context_file( - &repo_paths.context_overview_file(), - CONTEXT_OVERVIEW_TEMPLATE, - )?; - ensure_context_file( - &repo_paths.context_architecture_file(), - CONTEXT_ARCHITECTURE_TEMPLATE, - )?; - ensure_context_file( - &repo_paths.context_patterns_file(), - CONTEXT_PATTERNS_TEMPLATE, - )?; - ensure_context_file( - &repo_paths.context_glossary_file(), - CONTEXT_GLOSSARY_TEMPLATE, - )?; - ensure_context_file(&repo_paths.context_map_file(), CONTEXT_MAP_TEMPLATE)?; - ensure_context_file( - &repo_paths.context_tmp_gitignore_file(), - CONTEXT_TMP_GITIGNORE_CONTENT, - )?; - - Ok(success("Context baseline ensured.")) -} - -fn ensure_context_directory(path: &Path) -> Result<()> { - fs::create_dir_all(path) - .with_context(|| format!("Failed to create context directory '{}'", path.display())) -} - -fn ensure_context_file(path: &Path, content: &str) -> Result<()> { - if path.exists() { - return Ok(()); - } + use crate::adapters::inbound::cli::setup::render_context_baseline_report; + use crate::adapters::outbound::filesystem::context_store::FilesystemContextStore; + use crate::application::use_cases::ensure_context_baseline::{ + EnsureContextBaseline, EnsureContextBaselineRequest, + }; - if let Some(parent) = path.parent() { - ensure_context_directory(parent)?; - } + let use_case = EnsureContextBaseline::new(FilesystemContextStore); + let report = use_case + .execute(EnsureContextBaselineRequest { + repository_root: repository_root.to_path_buf(), + }) + .with_context(|| { + format!( + "Failed to ensure context baseline for '{}'", + repository_root.display() + ) + })?; - fs::write(path, content) - .with_context(|| format!("Failed to write context baseline file '{}'", path.display())) + Ok(render_context_baseline_report(&report)) } fn format_setup_install_success_message(outcome: &SetupInstallOutcome) -> String { @@ -1680,19 +1626,19 @@ mod tests { } fn assert_baseline_paths_exist(repo: &Path) { - let paths = RepoPaths::new(repo); - for path in [ - paths.context_overview_file(), - paths.context_architecture_file(), - paths.context_patterns_file(), - paths.context_glossary_file(), - paths.context_map_file(), - paths.context_plans_dir(), - paths.context_handovers_dir(), - paths.context_decisions_dir(), - paths.context_tmp_dir(), - paths.context_tmp_gitignore_file(), + for relative_path in [ + "context/overview.md", + "context/architecture.md", + "context/patterns.md", + "context/glossary.md", + "context/context-map.md", + "context/plans", + "context/handovers", + "context/decisions", + "context/tmp", + "context/tmp/.gitignore", ] { + let path = repo.join(relative_path); assert!(path.exists(), "expected baseline path {}", path.display()); } } @@ -1839,9 +1785,9 @@ mod tests { assert!(!paths.claude_dir().exists()); assert!(!paths.pi_dir().exists()); - let gitignore = fs::read_to_string(paths.context_tmp_gitignore_file()) + let gitignore = fs::read_to_string(repo.join("context/tmp/.gitignore")) .expect("tmp gitignore should be readable"); - assert_eq!(gitignore, CONTEXT_TMP_GITIGNORE_CONTENT); + assert_eq!(gitignore, "*\n!.gitignore\n"); let _ = fs::remove_dir_all(&repo); } @@ -1851,33 +1797,36 @@ mod tests { let repo = init_git_repo("idempotent-baseline"); bootstrap_context_baseline(&repo).expect("initial bootstrap"); - let paths = RepoPaths::new(&repo); + let overview_file = repo.join("context/overview.md"); + let context_map_file = repo.join("context/context-map.md"); + let tmp_gitignore_file = repo.join("context/tmp/.gitignore"); + let architecture_file = repo.join("context/architecture.md"); + let plans_dir = repo.join("context/plans"); + let sentinel = "SENTINEL_OVERVIEW_CONTENT\n"; - fs::write(paths.context_overview_file(), sentinel).expect("seed overview sentinel"); - fs::write(paths.context_map_file(), "SENTINEL_CONTEXT_MAP\n") - .expect("seed context-map sentinel"); - fs::write(paths.context_tmp_gitignore_file(), "SENTINEL_GITIGNORE\n") - .expect("seed gitignore sentinel"); + fs::write(&overview_file, sentinel).expect("seed overview sentinel"); + fs::write(&context_map_file, "SENTINEL_CONTEXT_MAP\n").expect("seed context-map sentinel"); + fs::write(&tmp_gitignore_file, "SENTINEL_GITIGNORE\n").expect("seed gitignore sentinel"); - fs::remove_file(paths.context_architecture_file()).expect("remove architecture"); - fs::remove_dir_all(paths.context_plans_dir()).expect("remove plans"); + fs::remove_file(&architecture_file).expect("remove architecture"); + fs::remove_dir_all(&plans_dir).expect("remove plans"); bootstrap_context_baseline(&repo).expect("rerun bootstrap"); assert_eq!( - fs::read_to_string(paths.context_overview_file()).expect("read overview"), + fs::read_to_string(&overview_file).expect("read overview"), sentinel ); assert_eq!( - fs::read_to_string(paths.context_map_file()).expect("read context-map"), + fs::read_to_string(&context_map_file).expect("read context-map"), "SENTINEL_CONTEXT_MAP\n" ); assert_eq!( - fs::read_to_string(paths.context_tmp_gitignore_file()).expect("read gitignore"), + fs::read_to_string(&tmp_gitignore_file).expect("read gitignore"), "SENTINEL_GITIGNORE\n" ); - assert!(paths.context_architecture_file().exists()); - assert!(paths.context_plans_dir().is_dir()); + assert!(architecture_file.exists()); + assert!(plans_dir.is_dir()); let _ = fs::remove_dir_all(&repo); } diff --git a/context/architecture.md b/context/architecture.md index 1b60625e..0098ce3b 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -132,8 +132,25 @@ Migration proceeds through vertical slices rather than a big-bang rewrite: one command or capability at a time gets domain types, application use cases/ports, and adapters carved out of `services`, with `composition::run` progressively wiring more of the CLI through the new layers while -unconverted commands keep flowing through `app::run`. No slice migration is -in scope for this skeleton phase. +unconverted commands keep flowing through `app::run`. + +The first landed slice is `sce setup`'s durable-context baseline bootstrap: +`cli/src/domain/context/baseline.rs` defines the `ContextBaseline` manifest, +`cli/src/application/ports/context_store.rs` and +`cli/src/application/use_cases/ensure_context_baseline.rs` define the +`ContextStore` port and `EnsureContextBaseline` use case, and +`cli/src/adapters/outbound/filesystem/context_store.rs` implements +`ContextStore` against the filesystem. `services::setup::bootstrap_context_baseline` +is now a compatibility facade: it constructs the filesystem adapter, runs the +use case, and renders the result through +`cli/src/adapters/inbound/cli/setup.rs`'s `render_context_baseline_report`, +so both `sce setup --bootstrap-context` and every normal successful `sce +setup` run — which already shared this one call site — pick up the layered +implementation with unchanged output. `composition::run` is not yet wired +through this slice; `setup`'s command parsing, prompts, and lifecycle +providers still flow through `app::run`/`services::setup` unchanged. See +`context/sce/setup-repo-local-config-bootstrap.md` for the full behavior +contract this slice preserves. ```mermaid flowchart LR diff --git a/context/cli/default-path-catalog.md b/context/cli/default-path-catalog.md index 2e4ef6e6..f11beb2a 100644 --- a/context/cli/default-path-catalog.md +++ b/context/cli/default-path-catalog.md @@ -26,7 +26,7 @@ - `.claude/` - `.pi/` - `.git/`, `.git/hooks/`, `.git/COMMIT_EDITMSG` -- `context/`, `context/plans/`, `context/decisions/`, `context/handovers/`, `context/tmp/`, `context/tmp/.gitignore` via `RepoPaths::context_tmp_gitignore_file()` +- `context/`, `context/plans/`, `context/decisions/`, `context/handovers/`, `context/tmp/`, `context/tmp/.gitignore` — no longer `RepoPaths` accessors; the canonical manifest is `ContextBaseline::sce_default()` (`cli/src/domain/context/baseline.rs`), and `FilesystemContextStore` (`cli/src/adapters/outbound/filesystem/context_store.rs`) builds these paths by joining `repository_root` with the manifest's own relative paths (see `../architecture.md`'s "CLI internal hexagonal architecture") ### Install paths diff --git a/context/glossary.md b/context/glossary.md index 8d553afa..d4e701e4 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -102,7 +102,11 @@ - `setup hooks CLI mode`: `sce setup` behavior activated by `--hooks` (with optional `--repo `), supporting both hooks-only runs and composable target+hooks runs in one invocation; implemented through `cli/src/services/setup/command.rs` + `cli/src/services/setup/mod.rs`, enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits stable setup/hook status output. - `setup repo gate`: Preflight check in `cli/src/services/setup/command.rs` that calls `cli/src/services/setup/mod.rs` (`ensure_git_repository`) before any setup writes begin; enforces that all `sce setup` modes (config-only, hooks-only, combined, and interactive) require the current directory to be inside a git repository, failing with actionable guidance to run `git init` and rerun `sce setup` when the precondition is not met. - `setup local bootstrap`: Pre-install setup bootstrap behavior now owned by lifecycle providers: `ConfigLifecycle::setup` creates missing `.sce/config.json` with the canonical schema-only payload (`{"$schema": "https://sce.crocoder.dev/config.json"}`), `LocalDbLifecycle::setup` initializes the canonical local DB via `LocalDb::new()`, and `AgentTraceDbLifecycle::setup` creates/reuses checkout identity, resolves repository identity, initializes the repository-scoped Agent Trace DB via `agent_trace_storage`, and records repository ID, checkout ID, and `database_path`; the setup command aggregates these calls before config/hooks dispatch across all normal setup modes after context baseline bootstrap. -- `setup context baseline bootstrap`: Additive durable-context tree bootstrap in `cli/src/services/setup/mod.rs` (`bootstrap_context_baseline`) that create-if-missing writes neutral baseline Markdown files, working directories, and `context/tmp/.gitignore` via `RepoPaths` accessors. `sce setup --bootstrap-context` is the dedicated context-only mode and must be used alone; every normal successful setup path also ensures the same baseline after the Git gate and before lifecycle/config install work without overwriting existing content. +- `setup context baseline bootstrap`: Additive durable-context tree bootstrap that create-if-missing writes neutral baseline Markdown files, working directories, and `context/tmp/.gitignore`. `services::setup::bootstrap_context_baseline` (`cli/src/services/setup/mod.rs`) is now a thin compatibility facade over the CLI's internal hexagonal layers — see `ContextBaseline`, `ContextStore`, `EnsureContextBaseline`, and `FilesystemContextStore`. `sce setup --bootstrap-context` is the dedicated context-only mode and must be used alone; every normal successful setup path also ensures the same baseline after the Git gate and before lifecycle/config install work without overwriting existing content. +- `ContextBaseline`: Domain model in `cli/src/domain/context/baseline.rs` (`ContextBaseline`, `BaselineFile`, `ContextBaseline::sce_default()`) defining the canonical durable-context directory/file manifest — 5 directories and 6 files with their relative paths and neutral initial content — with no dependency on infrastructure, ports, or adapters. +- `ContextStore`: Application port in `cli/src/application/ports/context_store.rs` — a trait with an associated `Error` type and `ensure_baseline(&self, repository_root: &Path, baseline: &ContextBaseline) -> Result` — plus `ContextBaselineChanges` (`created_directories`, `existing_directories`, `created_files`, `existing_files`). Implemented by the `FilesystemContextStore` outbound adapter and injected into the `EnsureContextBaseline` use case. +- `EnsureContextBaseline`: Application use case in `cli/src/application/use_cases/ensure_context_baseline.rs` that resolves `ContextBaseline::sce_default()` and delegates persistence to an injected `ContextStore`, returning an `EnsureContextBaselineReport` (repository root plus `ContextBaselineChanges`). Generic over any `S: ContextStore`, with no dependency on `crate::services` or `crate::adapters`. +- `FilesystemContextStore`: Outbound adapter in `cli/src/adapters/outbound/filesystem/context_store.rs` implementing `ContextStore` against the real filesystem: creates missing baseline directories via `fs::create_dir_all`, writes missing baseline files with their initial content, and leaves existing directories/files untouched, recording each path under the matching `created_*`/`existing_*` field of `ContextBaselineChanges`. - `CLI redaction-safe diagnostics contract`: baseline security behavior implemented via `cli/src/services/security.rs` (`redact_sensitive_text`) and applied to app-level errors, setup git-diagnostic surfacing, and observability output sinks so common secret-bearing token forms are masked before emission. - `setup directory write-permission probe`: deterministic pre-write guard implemented in `cli/src/services/security.rs` (`ensure_directory_is_writable`) and used by setup install/hook flows to fail fast with actionable remediation when target directories are not writable. - `setup --repo canonical path guard`: setup-hook runtime behavior in `cli/src/services/setup/mod.rs` that canonicalizes and validates user-supplied `--repo` paths as existing directories before git-root/hooks-path resolution. diff --git a/context/plans/migrate-context-baseline-vertical-slice.md b/context/plans/migrate-context-baseline-vertical-slice.md new file mode 100644 index 00000000..a8930106 --- /dev/null +++ b/context/plans/migrate-context-baseline-vertical-slice.md @@ -0,0 +1,479 @@ +# Plan: migrate-context-baseline-vertical-slice + +## Change summary + +Migrate `sce setup`'s durable-context baseline bootstrap — currently +`services::setup::bootstrap_context_baseline` in `cli/src/services/setup/mod.rs`, +which mixes the canonical directory/file manifest, `RepoPaths`-based path +calculation, direct `std::fs` I/O, and styled success-string rendering into one +function — into the hexagonal skeleton landed by +`context/plans/cli-hexagonal-architecture-skeleton.md`. This is the skeleton's +first real vertical slice: a `ContextBaseline` domain model +(`cli/src/domain/context/`), a narrow `ContextStore` application port and +`EnsureContextBaseline` use case (`cli/src/application/`), a +`FilesystemContextStore` outbound adapter that performs the actual I/O +(`cli/src/adapters/outbound/filesystem/`), and an inbound renderer +(`cli/src/adapters/inbound/cli/setup.rs`). `services::setup::bootstrap_context_baseline` +becomes a thin compatibility facade that constructs the adapter, runs the use +case, and renders the report — so both call sites that already route through +it (`sce setup --bootstrap-context` and every normal successful `sce setup` +run) pick up the new implementation automatically, with no change to the +public `sce setup` command surface, output text, or styling. + +This plan does not touch `SetupRequest`, Clap parsing, repository discovery, +prompts, workflow selection, integration installation, lifecycle providers, or +any other part of `setup`. `composition::run` continues to delegate to +`app::run`; this slice proves the layering works end-to-end for one operation +without wiring `setup` itself through `composition.rs`. + +## Acceptance criteria + +- [x] AC1: The canonical context-baseline directory/file manifest is defined + once, in `cli/src/domain/context/baseline.rs`, and nowhere in + `cli/src/services/setup/mod.rs`. + - Validate: `test -f cli/src/domain/context/baseline.rs`; `grep -n "CONTEXT_OVERVIEW_TEMPLATE\|CONTEXT_MAP_TEMPLATE\|CONTEXT_TMP_GITIGNORE_CONTENT" cli/src/services/setup/mod.rs` matches nothing. +- [x] AC2: `EnsureContextBaseline::execute` depends only on the + application-owned `ContextStore` port and the domain `ContextBaseline` + type — no `crate::services`, `crate::adapters`, or infrastructure import. + - Validate: `grep -n "crate::services\|crate::adapters\|std::fs" cli/src/application/use_cases/ensure_context_baseline.rs` matches nothing; `./scripts/check-cli-architecture.sh` passes. +- [x] AC3: All filesystem access for the migrated baseline path (directory + creation, existence checks, file writes) lives in + `cli/src/adapters/outbound/filesystem/context_store.rs` and nowhere in + `domain` or `application`. + - Validate: `grep -rn "std::fs\|Path::exists\|fs::create_dir_all\|fs::write" cli/src/domain cli/src/application` matches nothing. +- [x] AC4: An existing baseline file with custom content is left byte-for-byte + unchanged by a bootstrap run. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml bootstrap_context_baseline_is_additive_and_idempotent` +- [x] AC5: Every canonical baseline directory and file is created when + missing. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml bootstrap_context_baseline_creates_expected_paths` +- [x] AC6: Running the use case twice against the same repository root + produces no file-content changes, and the second run's + `ContextBaselineChanges` reports every baseline path under + `existing_directories`/`existing_files` rather than the `created_*` fields. + - Validate: new `FilesystemContextStore` unit test asserting the second + `ensure_baseline` call's returned `ContextBaselineChanges` on an + already-bootstrapped tree. +- [x] AC7: Both `sce setup --bootstrap-context` and a normal successful `sce + setup` run invoke the migrated `EnsureContextBaseline` use case, because + both already route through the single `bootstrap_context_baseline` call + site in `cli/src/services/setup/command.rs:60`. + - Validate: existing tests `resolve_setup_request_accepts_bootstrap_context_alone`, + `parser_routes_bootstrap_context_to_context_only_request`, and the two + `bootstrap_context_baseline_*` tests all continue to pass unchanged. +- [x] AC8: `sce setup --bootstrap-context` and normal setup still emit + `Context baseline ensured.` with unchanged styling. + - Validate: `grep -n "Context baseline ensured" cli/src/adapters/inbound/cli/setup.rs`; existing test assertions `message.contains("Context baseline ensured.")` continue to pass. + +### Full validation + +- `nix flake check` +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` +- `./scripts/check-cli-architecture.sh` +- `./scripts/test-check-cli-architecture.sh` + +### Context sync + +- `context/architecture.md` (`## CLI internal hexagonal architecture` + currently states "No slice migration is in scope for this skeleton phase"; + that sentence goes stale once this plan lands and needs a first-slice note) +- `context/sce/setup-repo-local-config-bootstrap.md` (`## Implementation` + currently describes `bootstrap_context_baseline` as calling + `ensure_context_directory`/`ensure_context_file` directly against + `RepoPaths` accessors; needs to describe the new + domain/application/adapter path) +- `context/glossary.md` (optional new entries for `ContextStore`, + `EnsureContextBaseline`, and `FilesystemContextStore`, following the + existing pattern for other adapter/seam entries such as `local Turso + adapter`) + +## Constraints and non-goals + +- **In scope:** new `cli/src/domain/context/{mod.rs,baseline.rs}`; new + `cli/src/application/ports/context_store.rs`; new + `cli/src/application/use_cases/ensure_context_baseline.rs`; new + `cli/src/adapters/outbound/filesystem/{mod.rs,context_store.rs}`; new + `cli/src/adapters/inbound/cli/setup.rs`; the corresponding `mod` + declarations in `cli/src/domain/mod.rs`, `cli/src/application/ports/mod.rs`, + `cli/src/application/use_cases/mod.rs`, `cli/src/adapters/outbound/mod.rs`, + `cli/src/adapters/inbound/cli/mod.rs`; `cli/src/services/setup/mod.rs` + (reducing `bootstrap_context_baseline` to a compatibility facade and + removing the now-superseded `CONTEXT_*_TEMPLATE` constants, + `ensure_context_directory`, `ensure_context_file`, and their tests' + internal path construction); `cli/src/services/default_paths.rs` (removing + the `RepoPaths::context_*` accessors once the migrated path makes them + unused, to avoid dead-code warnings under `cargo build`/`clippy -D + warnings`); the listed `context/**` files. +- **Out of scope:** `SetupRequest`, Clap setup parsing, repository discovery, + interactive prompts, optional-workflow selection, integration + installation, lifecycle providers, config bootstrap, database + initialization, hooks, `AppContext`, any other part of the `setup` command; + `composition.rs` (setup is not wired through it in this phase); + `services::setup::command.rs` beyond the fact that its existing call to + `bootstrap_context_baseline` is unchanged. +- **Constraints:** single Cargo package, no new crate dependencies; `domain` + and `application` code must satisfy `scripts/check-cli-architecture.sh`; + the rendered `"Context baseline ensured."` output stays byte-for-byte + identical; existing tests that assert on `bootstrap_context_baseline`'s + return value keep passing without relaxing their assertions. +- **Non-goal:** migrating any other part of `setup`, or any other command, in + this plan. `application/ports` gains exactly one port + (`ContextStore`); no speculative `FileSystem`/`Database`/`HttpClient`/ + `Clock`/`Logger` port is added. +- **Non-goal:** exposing the created/existing file lists in the CLI's + rendered output. `render_context_baseline_report` renders only the fixed + success string; `ContextBaselineChanges` is proven through tests, not + through a new output contract. + +## Assumptions + +- `EnsureContextBaseline::execute` returns `Result` + directly rather than introducing a separate `EnsureContextBaselineError` + wrapper type, since `ContextStore::Error` is already the operation's only + failure mode and an application-owned wrapper would add a layer with + nothing to say. `services::setup::bootstrap_context_baseline` converts that + error to `anyhow::Error` at the compatibility-facade boundary, same as + today. +- `RepoPaths::context_dir()`, `context_plans_dir()`, `context_handovers_dir()`, + `context_decisions_dir()`, `context_tmp_dir()`, `context_overview_file()`, + `context_architecture_file()`, `context_patterns_file()`, + `context_glossary_file()`, `context_map_file()`, and + `context_tmp_gitignore_file()` in `cli/src/services/default_paths.rs` have + no callers outside `services/setup/mod.rs` (confirmed by repository search). + Once the migrated adapter builds baseline paths by joining + `repository_root` with `ContextBaseline`'s own relative-path strings + instead of calling these accessors, they become dead code and are removed + in the same task, with the two existing tests that currently call them + switched to plain `repo.join("context")`-style path construction for their + assertions. This keeps `cargo build`/`clippy --all-targets --all-features + -D warnings` clean, consistent with T01's "unused-code-clean" bar in the + hexagonal-skeleton plan. +- `ContextBaseline::sce_default()`'s directory and file lists, and every + file's `initial_content`, are byte-identical to the current + `CONTEXT_*_TEMPLATE` constants and `CONTEXT_TMP_GITIGNORE_CONTENT` in + `cli/src/services/setup/mod.rs`, per `context/sce/setup-repo-local-config-bootstrap.md`'s + "Baseline paths" list — this plan relocates that content, it does not + change it. + +## Task stack + +- [x] T01: `Add the domain ContextBaseline model` (status:done) + - Task ID: T01 + - Goal: Define `ContextBaseline`, `BaselineFile`, and + `ContextBaseline::sce_default()` in `cli/src/domain/context/baseline.rs`, + with relative paths and template content copied verbatim from the + current `CONTEXT_*_TEMPLATE` constants and directory list in + `cli/src/services/setup/mod.rs`. Add `cli/src/domain/context/mod.rs` and + wire `pub(crate) mod context;` into `cli/src/domain/mod.rs`. + - Boundaries (in/out of scope): In — `cli/src/domain/context/{mod.rs,baseline.rs}`, + the one-line `mod` addition to `cli/src/domain/mod.rs`. Out — any + application, adapter, or `services` change; this task does not wire the + new type into anything yet. + - Dependencies: none + - Done when: `ContextBaseline::sce_default()` returns the 5 canonical + directories (`context`, `context/plans`, `context/handovers`, + `context/decisions`, `context/tmp`) and 6 canonical files + (`context/overview.md`, `context/architecture.md`, `context/patterns.md`, + `context/glossary.md`, `context/context-map.md`, `context/tmp/.gitignore`) + with content matching the current constants; a domain-local unit test + asserts the directory/file counts and each relative path; + `./scripts/check-cli-architecture.sh` passes with the new file present. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml domain::context`; `./scripts/check-cli-architecture.sh`. + - Evidence: Added `cli/src/domain/context/baseline.rs` (`BaselineFile`, + `ContextBaseline`, `ContextBaseline::sce_default()`, with the + `CONTEXT_*_TEMPLATE`/`CONTEXT_TMP_GITIGNORE_CONTENT` content copied + verbatim from `cli/src/services/setup/mod.rs`) and + `cli/src/domain/context/mod.rs` (`pub(crate) mod baseline;`, no + re-export yet per the "does not wire the new type into anything yet" + boundary — an unused `pub(crate) use` would trip the workspace's + `warnings = "deny"` lint before T02 exists to consume it). Wired + `pub(crate) mod context;` into `cli/src/domain/mod.rs`. No other files + changed. + - `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml domain::context` → 2 passed (`sce_default_has_the_canonical_directories_and_files`, `sce_default_file_content_matches_legacy_templates`). + - `./scripts/check-cli-architecture.sh` → passed. + +- [x] T02: `Add the application ContextStore port` (status:done) + - Task ID: T02 + - Goal: Define `ContextStore` (a trait with an associated `Error` type and + an `ensure_baseline(&self, repository_root: &Path, baseline: + &ContextBaseline) -> Result` method) + and `ContextBaselineChanges` (`created_directories`, + `existing_directories`, `created_files`, `existing_files`, all + `Vec`) in `cli/src/application/ports/context_store.rs`. Wire + `pub(crate) mod context_store;` into `cli/src/application/ports/mod.rs`. + - Boundaries (in/out of scope): In — the port file and its `mod` + declaration only. Out — any concrete implementation (T04), the use case + (T03). + - Dependencies: T01 + - Done when: the port compiles against `crate::domain::context::ContextBaseline` + with no `crate::services` or `crate::adapters` dependency; + `./scripts/check-cli-architecture.sh` passes. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml`; `./scripts/check-cli-architecture.sh`. + - Evidence: Added `cli/src/application/ports/context_store.rs` + (`ContextBaselineChanges`, `ContextStore` trait with associated `Error` + and `ensure_baseline`, importing only `crate::domain::context::baseline::ContextBaseline`) + and wired `pub(crate) mod context_store;` into + `cli/src/application/ports/mod.rs`. No other files changed. Both new + items carry `#[allow(dead_code)]` (consistent with existing repo + convention, e.g. `cli/src/services/default_paths.rs`) since nothing + constructs or implements them until T03/T04. + - `./scripts/check-cli-architecture.sh` → passed. + - `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml` with + `RUSTFLAGS="--cap-lints warn"` → 0 type errors, confirming the port + compiles cleanly against `ContextBaseline` with no + `crate::services`/`crate::adapters` dependency. + - Plain `./scripts/run-cli-cargo.sh build`/`test` (workspace + `warnings = "deny"`) still fails, but only on the 7 pre-existing + `never used`/`never constructed` errors in + `cli/src/domain/context/baseline.rs` left by T01 (confirmed by + reverting this task's two changes and re-running: same 7 errors, + unchanged). No error originates from this task's new file. This is + expected transitional state for the slice — `ContextBaseline` and + `ContextStore` stay unconsumed by production code until T03 (use + case) and T05 (compatibility facade) wire them in, the same pattern + already called out for `RepoPaths::context_*` in this plan's + Assumptions. + +- [x] T03: `Add the EnsureContextBaseline use case` (status:done) + - Task ID: T03 + - Goal: Define `EnsureContextBaseline`, + `EnsureContextBaselineRequest { repository_root: PathBuf }`, and + `EnsureContextBaselineReport { repository_root: PathBuf, changes: + ContextBaselineChanges }` in + `cli/src/application/use_cases/ensure_context_baseline.rs`, with + `execute` calling `ContextBaseline::sce_default()` and delegating to the + injected `ContextStore`. Wire `pub(crate) mod ensure_context_baseline;` + into `cli/src/application/use_cases/mod.rs`. + - Boundaries (in/out of scope): In — the use-case file and its `mod` + declaration. Out — any concrete `ContextStore` implementation or + call-site wiring. + - Dependencies: T02 + - Done when: `EnsureContextBaseline::execute` compiles generically over any + `S: ContextStore` with no `crate::services`/`crate::adapters` import; + a use-case-level unit test with an in-memory fake `ContextStore` + confirms `execute` calls `ensure_baseline` with the resolved repository + root and `ContextBaseline::sce_default()`; `./scripts/check-cli-architecture.sh` + passes. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml ensure_context_baseline`; `./scripts/check-cli-architecture.sh`. + - Evidence: Added `cli/src/application/use_cases/ensure_context_baseline.rs` + (`EnsureContextBaselineRequest`, `EnsureContextBaselineReport`, + `EnsureContextBaseline` with `new`/`execute`; `execute` + calls `ContextBaseline::sce_default()` and delegates to the injected + `ContextStore::ensure_baseline`, returning + `Result` per the plan's + Assumptions; imports only `crate::application::ports::context_store` and + `crate::domain::context::baseline`) and a unit test using an in-memory + `FakeContextStore` that records call arguments and asserts `execute` + calls `ensure_baseline` with the resolved repository root and + `ContextBaseline::sce_default()`. Wired + `pub(crate) mod ensure_context_baseline;` into + `cli/src/application/use_cases/mod.rs`. All new items carry + `#[allow(dead_code)]` since nothing constructs `EnsureContextBaseline` + from production code until T05, consistent with T01/T02. No other files + changed. + - `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml ensure_context_baseline` → 1 passed (`execute_calls_ensure_baseline_with_resolved_root_and_default_baseline`). + - `./scripts/check-cli-architecture.sh` → passed. + +- [x] T04: `Add the FilesystemContextStore outbound adapter` (status:done) + - Task ID: T04 + - Goal: Implement `ContextStore` for a new `FilesystemContextStore` in + `cli/src/adapters/outbound/filesystem/context_store.rs`: for each + baseline directory, create it if missing via `fs::create_dir_all` and + record created-vs-existing; for each baseline file, skip (record + existing) if the path exists, otherwise create parent directories and + write the initial content (record created); return a path-specific error + type on I/O failure. Add `cli/src/adapters/outbound/filesystem/mod.rs` + and wire `pub(crate) mod filesystem;` into + `cli/src/adapters/outbound/mod.rs`. + - Boundaries (in/out of scope): In — the adapter file, its `mod` + declaration, and its own unit tests. Out — the compatibility facade + wiring in `services::setup` (T05). + - Dependencies: T03 + - Done when: adapter unit tests (temp-directory based) prove: a missing + baseline is fully created; an existing file with custom content is left + byte-for-byte unchanged on rerun; a second `ensure_baseline` call against + an already-bootstrapped tree reports every path under + `existing_directories`/`existing_files` and nothing under + `created_directories`/`created_files`; a partially-bootstrapped tree + (some paths present, some missing) creates only the missing ones. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::context_store`. + - Evidence: Added `cli/src/adapters/outbound/filesystem/context_store.rs` + (`ContextStoreError { path, source: std::io::Error }` implementing + `std::error::Error`/`Display`; `FilesystemContextStore`, a unit struct + implementing `ContextStore` by joining each baseline directory/file's + relative path against `repository_root`, creating missing directories + via `fs::create_dir_all`, skipping existing files untouched, and writing + missing files after creating their parent directory, recording each path + under the matching `created_*`/`existing_*` field) and + `cli/src/adapters/outbound/filesystem/mod.rs` + (`pub(crate) mod context_store;`). Wired + `pub(crate) mod filesystem;` into `cli/src/adapters/outbound/mod.rs`. + Four temp-directory-based unit tests cover full creation, byte-for-byte + idempotency of custom content, second-run existing-path reporting, and + partial-tree creation of only missing paths (using the repository's + existing hand-rolled `unique_temp_dir` helper pattern from + `services::setup::tests`, since the crate has no `tempfile` dev-dependency). + Also changed `mod ports;` to `pub(crate) mod ports;` in + `cli/src/application/mod.rs` — required so `crate::adapters` (an outward + layer per `context/architecture.md`'s "Adapters depend inward on ports + the application layer owns") can reference + `crate::application::ports::context_store`; `use_cases` remained + unaffected since it lives inside `application` and already had access. + No other files changed. + - `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::context_store` → 4 passed (`ensure_baseline_creates_a_missing_baseline_fully`, `ensure_baseline_leaves_existing_custom_content_byte_for_byte_unchanged`, `ensure_baseline_second_run_reports_only_existing_paths`, `ensure_baseline_creates_only_missing_paths_in_a_partially_bootstrapped_tree`). + - `./scripts/check-cli-architecture.sh` → passed. + - `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (full suite) → 190 passed, 0 failed. + - `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets --all-features -- -D warnings` → clean. + +- [x] T05: `Wire the inbound renderer and compatibility facade, retire the legacy implementation` (status:done) + - Task ID: T05 + - Goal: Add `render_context_baseline_report(report: &EnsureContextBaselineReport) + -> String` in `cli/src/adapters/inbound/cli/setup.rs` (returning + `services::style::success("Context baseline ensured.")`, unchanged + text/styling), wired via `pub(crate) mod setup;` in + `cli/src/adapters/inbound/cli/mod.rs`. Reduce + `services::setup::bootstrap_context_baseline` to a facade that + constructs a `FilesystemContextStore`, runs + `EnsureContextBaseline::execute`, maps any error through + `anyhow::Context` (same error-message shape as today), and renders the + report. Remove the now-superseded `CONTEXT_*_TEMPLATE` constants, + `CONTEXT_TMP_GITIGNORE_CONTENT`, `ensure_context_directory`, and + `ensure_context_file` from `cli/src/services/setup/mod.rs`. Remove the + now-unused `RepoPaths::context_*` accessors from + `cli/src/services/default_paths.rs` and update the two + `bootstrap_context_baseline_*` tests in `services/setup/mod.rs` to + construct expected paths by joining the repo root directly instead of + through `RepoPaths`. + - Boundaries (in/out of scope): In — the files named above. Out — anything + in `services/setup/command.rs` (its existing call to + `bootstrap_context_baseline` needs no change), Clap parsing, prompts, + lifecycle providers. + - Dependencies: T04 + - Done when: `bootstrap_context_baseline_creates_expected_paths`, + `bootstrap_context_baseline_is_additive_and_idempotent`, + `resolve_setup_request_accepts_bootstrap_context_alone`, + `resolve_setup_request_rejects_bootstrap_context_with_target`, + `parser_routes_bootstrap_context_to_context_only_request`, and + `help_documents_bootstrap_context_flag` all continue to pass unchanged; + `cargo build`/`clippy --all-targets --all-features -D warnings` is clean + (no dead-code warnings from removed `RepoPaths` accessors or removed + constants); `./scripts/check-cli-architecture.sh` and + `./scripts/test-check-cli-architecture.sh` pass. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml`; `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets --all-features -- -D warnings`; `./scripts/check-cli-architecture.sh`; `./scripts/test-check-cli-architecture.sh`. + - Evidence: Added `cli/src/adapters/inbound/cli/setup.rs` + (`render_context_baseline_report`, returning + `services::style::success("Context baseline ensured.")` unchanged) wired + via `pub(crate) mod setup;` in `cli/src/adapters/inbound/cli/mod.rs`. + Rewrote `services::setup::bootstrap_context_baseline` in + `cli/src/services/setup/mod.rs` to construct a `FilesystemContextStore`, + run `EnsureContextBaseline::execute`, map any error through + `anyhow::Context` (same message shape as the legacy + `ensure_context_directory`/`ensure_context_file` `.with_context` calls), + and render via `render_context_baseline_report`. Removed the superseded + `CONTEXT_OVERVIEW_TEMPLATE`, `CONTEXT_ARCHITECTURE_TEMPLATE`, + `CONTEXT_PATTERNS_TEMPLATE`, `CONTEXT_GLOSSARY_TEMPLATE`, + `CONTEXT_MAP_TEMPLATE`, `CONTEXT_TMP_GITIGNORE_CONTENT`, + `ensure_context_directory`, and `ensure_context_file` from + `cli/src/services/setup/mod.rs`. Removed the now-unused + `RepoPaths::context_dir`/`context_plans_dir`/`context_decisions_dir`/ + `context_handovers_dir`/`context_tmp_dir`/`context_overview_file`/ + `context_architecture_file`/`context_glossary_file`/`context_patterns_file`/ + `context_map_file`/`context_tmp_gitignore_file` accessors from + `cli/src/services/default_paths.rs`, along with the `context_dir` module + and the now-unused `context_file` constants those accessors alone + consumed (kept `context_file::SKILL_DEFINITION`, which has an unrelated + caller). Updated `assert_baseline_paths_exist`, + `bootstrap_context_baseline_creates_expected_paths`, and + `bootstrap_context_baseline_is_additive_and_idempotent` in + `services/setup/mod.rs` to construct expected paths via + `repo.join("context/...")` instead of `RepoPaths` accessors. + Bumped `mod` visibility to `pub(crate)` on `adapters::inbound`, + `adapters::outbound`, `adapters::inbound::cli`, and + `application::use_cases` (in `cli/src/adapters/mod.rs`, + `cli/src/adapters/inbound/mod.rs`, and `cli/src/application/mod.rs`) so + the facade in `services::setup` — a sibling of `adapters`/`application` + under the crate root, not a descendant — can reach + `EnsureContextBaseline` and `FilesystemContextStore`; this mirrors T04's + identical fix for `application::ports`. Removed the now-inaccurate + `#[allow(dead_code)] // consumed starting with the compatibility facade + (T05)` attributes from `cli/src/application/use_cases/ensure_context_baseline.rs` + and `cli/src/adapters/outbound/filesystem/context_store.rs`, since those + items are genuinely consumed by the facade as of this task. + - `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` → 190 + passed, 0 failed, including + `bootstrap_context_baseline_creates_expected_paths`, + `bootstrap_context_baseline_is_additive_and_idempotent`, + `resolve_setup_request_accepts_bootstrap_context_alone`, + `resolve_setup_request_rejects_bootstrap_context_with_target`, + `parser_routes_bootstrap_context_to_context_only_request`, and + `help_documents_bootstrap_context_flag`. + - `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml + --all-targets --all-features -- -D warnings` → clean. + - `./scripts/check-cli-architecture.sh` → passed. + - `./scripts/test-check-cli-architecture.sh` → all 8 assertions passed. + - `grep -n "CONTEXT_OVERVIEW_TEMPLATE\|CONTEXT_MAP_TEMPLATE\|CONTEXT_TMP_GITIGNORE_CONTENT" cli/src/services/setup/mod.rs` → no matches (AC1). + - `grep -n "Context baseline ensured" cli/src/adapters/inbound/cli/setup.rs` → present (AC8). + +## Open questions + +None. The change request specifies the target file layout, type shapes, the +single existing call site both setup paths already share, the exact output +compatibility requirement, and an explicit non-goals list; the one material +implementation choice this plan had to resolve on its own — what happens to +`RepoPaths`'s now-unused `context_*` accessors once the adapter stops calling +them — is recorded under `Assumptions` rather than left open, since leaving +them in place would fail the repository's existing clean-build bar +(`clippy --all-targets --all-features -D warnings`) for no benefit. + +Separately, `context/cli/default-path-catalog.md` claims +`cli/src/services/default_paths.rs` "includes a regression test that scans +non-test Rust source under `cli/src/` and fails when new centralized +production path literals appear outside the default-path service." No such +test exists anywhere in the repository today. That drift is unrelated to this +plan's scope (it predates this change and isn't touched by it) and is called +out here only so it isn't mistaken for a constraint this plan must satisfy; +correcting it is a separate, later context-hygiene fix. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-04 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed, including `checks.x86_64-linux.cli-fmt`, `checks.x86_64-linux.cli-architecture`, and `checks.x86_64-linux.cli-tests`; new files under `cli/src` were staged with `git add` for the duration of this run since the flake's Nix source only sees git-tracked content, then unstaged again afterward) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (190 passed, 0 failed) +- `./scripts/check-cli-architecture.sh` -> exit 0 (no forbidden dependencies in domain or application layers) +- `./scripts/test-check-cli-architecture.sh` -> exit 0 (all 8 assertions passed) +- `test -f cli/src/domain/context/baseline.rs; grep -n "CONTEXT_OVERVIEW_TEMPLATE\|CONTEXT_MAP_TEMPLATE\|CONTEXT_TMP_GITIGNORE_CONTENT" cli/src/services/setup/mod.rs` -> exit 1/no match (AC1 satisfied) +- `grep -n "crate::services\|crate::adapters\|std::fs" cli/src/application/use_cases/ensure_context_baseline.rs` -> exit 1/no match (AC2 satisfied) +- `grep -rn "std::fs\|Path::exists\|fs::create_dir_all\|fs::write" cli/src/domain cli/src/application` -> exit 1/no match (AC3 satisfied) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml bootstrap_context_baseline_is_additive_and_idempotent` -> exit 0, 1 passed (AC4) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml bootstrap_context_baseline_creates_expected_paths` -> exit 0, 1 passed (AC5) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::context_store` -> exit 0, 4 passed (AC6) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml resolve_setup_request_accepts_bootstrap_context_alone` -> exit 0, 1 passed (AC7) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml parser_routes_bootstrap_context_to_context_only_request` -> exit 0, 1 passed (AC7) +- `grep -n "Context baseline ensured" cli/src/adapters/inbound/cli/setup.rs` -> exit 0, match found (AC8) + +### Scaffolding removed + +None. + +### Success-criteria verification + +- [x] AC1: canonical manifest defined once in `baseline.rs`, absent from `services/setup/mod.rs` -> grep confirms no `CONTEXT_*_TEMPLATE` matches remain in `services/setup/mod.rs`; `baseline.rs` exists. +- [x] AC2: `EnsureContextBaseline::execute` has no `crate::services`/`crate::adapters`/`std::fs` import -> grep confirms no matches; `check-cli-architecture.sh` passed. +- [x] AC3: filesystem access confined to the outbound adapter -> grep across `cli/src/domain` and `cli/src/application` confirms no matches. +- [x] AC4: idempotent/additive on custom content -> `bootstrap_context_baseline_is_additive_and_idempotent` passed. +- [x] AC5: full creation when missing -> `bootstrap_context_baseline_creates_expected_paths` passed. +- [x] AC6: second run reports existing paths, not created -> `filesystem::context_store` suite (4 tests, including `ensure_baseline_second_run_reports_only_existing_paths`) passed. +- [x] AC7: both `sce setup --bootstrap-context` and normal setup route through the migrated use case -> `resolve_setup_request_accepts_bootstrap_context_alone`, `parser_routes_bootstrap_context_to_context_only_request`, `bootstrap_context_baseline_creates_expected_paths`, and `bootstrap_context_baseline_is_additive_and_idempotent` all passed unchanged. +- [x] AC8: unchanged success message/styling -> grep confirms `"Context baseline ensured."` in `cli/src/adapters/inbound/cli/setup.rs`; existing `message.contains(...)` assertions in the passing test suite confirm it. + +### Failed checks and follow-ups + +None. + +### Residual risks + +- None identified; all functional tests, the architecture gate, `nix flake check` (including formatting), and every acceptance criterion's own check passed. diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index 4a54cdca..2759825e 100644 --- a/context/sce/setup-repo-local-config-bootstrap.md +++ b/context/sce/setup-repo-local-config-bootstrap.md @@ -50,7 +50,7 @@ The same write also records the run's resolved optional-workflow selection under - `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>`, `bootstrap_context_baseline(repository_root: &Path) -> Result`, and `persist_integration_targets(repository_root: &Path, target: SetupTarget, selected_optional_workflows: &[String]) -> Result<()>`, which writes both `integrations.target` and `integrations.optional_workflows`. `run_setup_for_mode` resolves the selection (the selection handed to it, else the persisted value read through the exported `persisted_optional_workflows`, which parses the repo-local file via `parse_file_config`) before installing and persisting it. `cli/src/services/setup/command.rs` resolves the repository root before any prompt so it can seed the interactive prompt from that persisted value, and passes the prompted selection — when the run was interactive — to `run_setup_for_mode` ahead of the request's `--workflow` list. - `cli/src/services/local_db/lifecycle.rs` implements `LocalDbLifecycle::setup()` for local DB initialization. - `cli/src/services/agent_trace_db/lifecycle.rs` implements `AgentTraceDbLifecycle::setup()` for Agent Trace DB initialization. -- Repo-local config bootstrap uses `RepoPaths::sce_config_file()` and `RepoPaths::sce_dir()`; context baseline bootstrap uses the shared context accessors including `RepoPaths::context_tmp_gitignore_file()`. +- Repo-local config bootstrap uses `RepoPaths::sce_config_file()` and `RepoPaths::sce_dir()`. Context baseline bootstrap is implemented through the CLI's internal hexagonal layers (see `context/architecture.md`'s "CLI internal hexagonal architecture"): `bootstrap_context_baseline` is a thin compatibility facade that constructs a `FilesystemContextStore` outbound adapter (`cli/src/adapters/outbound/filesystem/context_store.rs`), runs the `EnsureContextBaseline` use case (`cli/src/application/use_cases/ensure_context_baseline.rs`) against the domain `ContextBaseline::sce_default()` manifest (`cli/src/domain/context/baseline.rs`), and renders the report through `render_context_baseline_report` (`cli/src/adapters/inbound/cli/setup.rs`), which returns the unchanged `Context baseline ensured.` success text. `RepoPaths` no longer has `context_*` accessors; the adapter builds baseline paths by joining `repository_root` with `ContextBaseline`'s own relative paths. - The canonical payload constant is `REPO_LOCAL_CONFIG_BOOTSTRAP_PAYLOAD`. - `cli/src/services/setup/command.rs` runs `bootstrap_context_baseline` immediately after `ensure_git_repository`. Context-only requests return there. Normal modes then derive a repo-root-scoped `AppContext` and aggregate lifecycle providers in config → local_db → auth_db → agent_trace_db → hooks order; `ConfigLifecycle::setup()` calls `bootstrap_repo_local_config(...)`, `LocalDbLifecycle::setup()` initializes the local DB, `AuthDbLifecycle::setup()` initializes the auth DB, and `AgentTraceDbLifecycle::setup()` initializes the Agent Trace DB. From fcdee2d9cbf41addb5e8badf83e59aa1dbf26ce0 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 4 Aug 2026 02:06:54 +0200 Subject: [PATCH 3/5] cli: Migrate embedded asset installation to hexagonal vertical slice install_embedded_setup_assets combined optional-workflow selection, embedded asset installation, config persistence, and CLI rendering in one services::setup module that also owned Git discovery, hook installation, filesystem staging, and prompting. Extract the embedded-asset installation capability into the internal hexagonal architecture, following the pattern established by the context-baseline slice: a pure domain model for integration targets/assets (domain/integration), two application ports (IntegrationAssetCatalog, IntegrationInstaller), one use case (InstallIntegrationAssets), and two outbound adapters (an embedded-asset catalog wrapping the existing generated catalog, and a filesystem installer owning staging/replace/rename/cleanup). Co-authored-by: SCE --- .../assets/embedded_integration_assets.rs | 97 +++++ cli/src/adapters/outbound/assets/mod.rs | 3 + .../filesystem/integration_installer.rs | 343 ++++++++++++++++++ cli/src/adapters/outbound/filesystem/mod.rs | 1 + cli/src/adapters/outbound/mod.rs | 1 + .../ports/integration_asset_catalog.rs | 18 + .../ports/integration_installer.rs | 31 ++ cli/src/application/ports/mod.rs | 2 + .../use_cases/install_integration_assets.rs | 231 ++++++++++++ cli/src/application/use_cases/mod.rs | 1 + cli/src/domain/integration/asset.rs | 10 + cli/src/domain/integration/mod.rs | 11 + cli/src/domain/integration/target.rs | 69 ++++ cli/src/domain/mod.rs | 1 + cli/src/services/setup/mod.rs | 240 +++--------- context/architecture.md | 34 +- context/glossary.md | 9 +- context/plans/install-integration-assets.md | 183 ++++++++++ 18 files changed, 1098 insertions(+), 187 deletions(-) create mode 100644 cli/src/adapters/outbound/assets/embedded_integration_assets.rs create mode 100644 cli/src/adapters/outbound/assets/mod.rs create mode 100644 cli/src/adapters/outbound/filesystem/integration_installer.rs create mode 100644 cli/src/application/ports/integration_asset_catalog.rs create mode 100644 cli/src/application/ports/integration_installer.rs create mode 100644 cli/src/application/use_cases/install_integration_assets.rs create mode 100644 cli/src/domain/integration/asset.rs create mode 100644 cli/src/domain/integration/mod.rs create mode 100644 cli/src/domain/integration/target.rs create mode 100644 context/plans/install-integration-assets.md diff --git a/cli/src/adapters/outbound/assets/embedded_integration_assets.rs b/cli/src/adapters/outbound/assets/embedded_integration_assets.rs new file mode 100644 index 00000000..e27e5b09 --- /dev/null +++ b/cli/src/adapters/outbound/assets/embedded_integration_assets.rs @@ -0,0 +1,97 @@ +//! `EmbeddedIntegrationAssetCatalog`: the `IntegrationAssetCatalog` outbound +//! adapter wrapping the existing generated embedded-asset catalog in +//! `services::setup`. + +use std::convert::Infallible; + +use crate::application::ports::integration_asset_catalog::IntegrationAssetCatalog; +use crate::domain::integration::{IntegrationAsset, IntegrationTarget}; +use crate::services::setup; + +fn setup_target_for(target: IntegrationTarget) -> setup::SetupTarget { + match target { + IntegrationTarget::OpenCode => setup::SetupTarget::OpenCode, + IntegrationTarget::Claude => setup::SetupTarget::Claude, + IntegrationTarget::Pi => setup::SetupTarget::Pi, + } +} + +/// Resolves embedded assets for a concrete integration target by delegating +/// to `services::setup::iter_embedded_assets_for_setup_target_with_selection`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct EmbeddedIntegrationAssetCatalog; + +impl IntegrationAssetCatalog for EmbeddedIntegrationAssetCatalog { + type Error = Infallible; + + fn assets_for( + &self, + target: IntegrationTarget, + optional_workflows: &[String], + ) -> Result, Self::Error> { + let assets = setup::iter_embedded_assets_for_setup_target_with_selection( + setup_target_for(target), + optional_workflows, + ) + .map(|asset| IntegrationAsset { + relative_path: asset.relative_path.to_string(), + bytes: asset.bytes, + }) + .collect(); + + Ok(assets) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assets_for_matches_the_underlying_generated_catalog() { + let optional_workflows = vec!["research".to_string()]; + + let adapter_assets = EmbeddedIntegrationAssetCatalog + .assets_for(IntegrationTarget::Claude, &optional_workflows) + .expect("adapter catalog lookup"); + + let expected: Vec = + setup::iter_embedded_assets_for_setup_target_with_selection( + setup::SetupTarget::Claude, + &optional_workflows, + ) + .map(|asset| IntegrationAsset { + relative_path: asset.relative_path.to_string(), + bytes: asset.bytes, + }) + .collect(); + + assert_eq!(adapter_assets, expected); + } + + #[test] + fn assets_for_maps_every_concrete_target() { + for target in [ + IntegrationTarget::OpenCode, + IntegrationTarget::Claude, + IntegrationTarget::Pi, + ] { + let adapter_assets = EmbeddedIntegrationAssetCatalog + .assets_for(target, &[]) + .expect("adapter catalog lookup"); + + let expected: Vec = + setup::iter_embedded_assets_for_setup_target_with_selection( + setup_target_for(target), + &[] as &[String], + ) + .map(|asset| IntegrationAsset { + relative_path: asset.relative_path.to_string(), + bytes: asset.bytes, + }) + .collect(); + + assert_eq!(adapter_assets, expected); + } + } +} diff --git a/cli/src/adapters/outbound/assets/mod.rs b/cli/src/adapters/outbound/assets/mod.rs new file mode 100644 index 00000000..cd5e055a --- /dev/null +++ b/cli/src/adapters/outbound/assets/mod.rs @@ -0,0 +1,3 @@ +//! Outbound adapters over the generated embedded-asset catalogs. + +pub(crate) mod embedded_integration_assets; diff --git a/cli/src/adapters/outbound/filesystem/integration_installer.rs b/cli/src/adapters/outbound/filesystem/integration_installer.rs new file mode 100644 index 00000000..d0354bca --- /dev/null +++ b/cli/src/adapters/outbound/filesystem/integration_installer.rs @@ -0,0 +1,343 @@ +//! `FilesystemIntegrationInstaller`: the `IntegrationInstaller` outbound +//! adapter that stages, writes, and swaps in embedded integration assets on +//! disk, porting the staging/replace/rename/cleanup logic previously inline +//! in `services::setup::install`. + +use std::{ + fs, io, + path::{Component, Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{bail, Context, Result}; + +use crate::application::ports::integration_installer::{ + InstalledIntegrationTarget, IntegrationInstaller, +}; +use crate::domain::integration::{IntegrationAsset, IntegrationTarget}; +use crate::services::default_paths::InstallTargetPaths; +use crate::services::security::ensure_directory_is_writable; +use crate::services::setup::{self, cleanup_path_if_exists, setup_install_recovery_guidance}; + +fn setup_target_for(target: IntegrationTarget) -> setup::SetupTarget { + match target { + IntegrationTarget::OpenCode => setup::SetupTarget::OpenCode, + IntegrationTarget::Claude => setup::SetupTarget::Claude, + IntegrationTarget::Pi => setup::SetupTarget::Pi, + } +} + +fn destination_root_for(repository_root: &Path, target: IntegrationTarget) -> PathBuf { + let install_targets = InstallTargetPaths::new(repository_root); + match target { + IntegrationTarget::OpenCode => install_targets.opencode_target_dir(), + IntegrationTarget::Claude => install_targets.claude_target_dir(), + IntegrationTarget::Pi => install_targets.pi_target_dir(), + } +} + +/// Installs embedded integration assets directly onto the filesystem via a +/// stage-then-rename swap, with no backup of any replaced target. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct FilesystemIntegrationInstaller; + +impl IntegrationInstaller for FilesystemIntegrationInstaller { + type Error = anyhow::Error; + + fn install( + &self, + repository_root: &Path, + target: IntegrationTarget, + assets: &[IntegrationAsset], + ) -> Result { + install_with_rename(repository_root, target, assets, |from, to| { + fs::rename(from, to) + }) + } +} + +fn install_with_rename( + repository_root: &Path, + target: IntegrationTarget, + assets: &[IntegrationAsset], + mut rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + ensure_directory_is_writable(repository_root, "setup repository root")?; + + let destination_root = destination_root_for(repository_root, target); + let staging_root = create_staging_root(repository_root, target)?; + + if let Err(error) = write_assets_to_staging(&staging_root, assets) { + cleanup_path_if_exists(&staging_root); + return Err(error); + } + + if destination_root.exists() { + remove_existing_install_target(&destination_root).with_context(|| { + format!( + "Failed to replace existing setup target '{}' without creating a backup", + destination_root.display() + ) + })?; + } + + if let Err(error) = rename_fn(&staging_root, &destination_root).with_context(|| { + format!( + "Failed to swap staged install '{}' into destination '{}'", + staging_root.display(), + destination_root.display() + ) + }) { + cleanup_path_if_exists(&staging_root); + return Err(error.context(setup_install_recovery_guidance( + setup_target_for(target), + &destination_root, + ))); + } + + Ok(InstalledIntegrationTarget { + target, + destination_root, + installed_file_count: assets.len(), + }) +} + +fn remove_existing_install_target(destination_root: &Path) -> Result<()> { + let metadata = fs::metadata(destination_root).with_context(|| { + format!( + "Failed to inspect existing setup target '{}'", + destination_root.display() + ) + })?; + + if metadata.is_dir() { + fs::remove_dir_all(destination_root).with_context(|| { + format!( + "Failed to remove existing setup target directory '{}'", + destination_root.display() + ) + })?; + } else { + fs::remove_file(destination_root).with_context(|| { + format!( + "Failed to remove existing setup target file '{}'", + destination_root.display() + ) + })?; + } + + Ok(()) +} + +fn write_assets_to_staging(staging_root: &Path, assets: &[IntegrationAsset]) -> Result<()> { + for asset in assets { + validate_embedded_relative_path(&asset.relative_path)?; + let destination = staging_root.join(&asset.relative_path); + let parent = destination + .parent() + .context("Embedded asset destination should have a parent directory")?; + + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create staged parent directory '{}'", + parent.display() + ) + })?; + + fs::write(&destination, asset.bytes).with_context(|| { + format!( + "Failed to write staged embedded asset '{}'", + destination.display() + ) + })?; + } + + Ok(()) +} + +fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { + let path = Path::new(relative_path); + + if path.is_absolute() { + bail!("Embedded asset path '{relative_path}' must be relative, not absolute"); + } + + for component in path.components() { + match component { + Component::Normal(_) => {} + _ => { + bail!("Embedded asset path '{relative_path}' contains disallowed component"); + } + } + } + + Ok(()) +} + +fn create_staging_root(repository_root: &Path, target: IntegrationTarget) -> Result { + let target_dir = destination_root_for(repository_root, target); + let target_label = target_dir + .file_name() + .and_then(|name| name.to_str()) + .context("Setup target directory should have a valid UTF-8 file name")? + .trim_start_matches('.'); + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos(); + + for attempt in 0..1000_u16 { + let candidate = repository_root.join(format!( + ".sce-setup-staging-{target_label}-{epoch_nanos}-{}-{attempt}", + std::process::id() + )); + + match fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "Failed to create staging directory '{}'", + candidate.display() + ) + }); + } + } + } + + bail!( + "Could not allocate a unique staging directory under '{}'", + repository_root.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-filesystem-integration-installer-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn asset(relative_path: &str, bytes: &'static [u8]) -> IntegrationAsset { + IntegrationAsset { + relative_path: relative_path.to_string(), + bytes, + } + } + + #[test] + fn install_writes_assets_to_the_target_directory() { + let repo = unique_temp_dir("success"); + let assets = vec![ + asset("command/next-task.md", b"next-task"), + asset("lib/helper.json", b"{}"), + ]; + + let installed = FilesystemIntegrationInstaller + .install(&repo, IntegrationTarget::Claude, &assets) + .expect("install should succeed"); + + assert_eq!(installed.target, IntegrationTarget::Claude); + assert_eq!(installed.installed_file_count, 2); + assert_eq!( + fs::read(installed.destination_root.join("command/next-task.md")) + .expect("read installed asset"), + b"next-task" + ); + assert_eq!( + fs::read(installed.destination_root.join("lib/helper.json")) + .expect("read installed asset"), + b"{}" + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn install_rejects_absolute_and_parent_component_paths() { + let repo = unique_temp_dir("invalid-path"); + + for invalid_path in ["/etc/passwd", "../escape.txt"] { + let assets = vec![asset(invalid_path, b"payload")]; + + let error = FilesystemIntegrationInstaller + .install(&repo, IntegrationTarget::Pi, &assets) + .expect_err("invalid embedded path should be rejected"); + + assert!(error.to_string().contains(invalid_path)); + assert!(!InstallTargetPaths::new(&repo).pi_target_dir().exists()); + } + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn install_replaces_an_existing_target_without_a_backup() { + let repo = unique_temp_dir("replace-existing"); + let assets = vec![asset("command/next-task.md", b"first")]; + + FilesystemIntegrationInstaller + .install(&repo, IntegrationTarget::OpenCode, &assets) + .expect("first install should succeed"); + + let replacement_assets = vec![asset("command/next-task.md", b"second")]; + let installed = FilesystemIntegrationInstaller + .install(&repo, IntegrationTarget::OpenCode, &replacement_assets) + .expect("second install should replace the first"); + + let entries: Vec<_> = fs::read_dir(&installed.destination_root) + .expect("read destination root") + .collect(); + assert_eq!(entries.len(), 1, "replaced target should have no backup"); + assert_eq!( + fs::read(installed.destination_root.join("command/next-task.md")) + .expect("read replaced asset"), + b"second" + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn install_cleans_up_staging_and_reports_recovery_guidance_on_rename_failure() { + let repo = unique_temp_dir("rename-failure"); + let assets = vec![asset("command/next-task.md", b"payload")]; + + let error = install_with_rename(&repo, IntegrationTarget::Claude, &assets, |_, _| { + Err(io::Error::other("simulated rename failure")) + }) + .expect_err("rename failure should surface an error"); + + let message = error.to_string(); + assert!(message.contains("does not create backups")); + assert!(!InstallTargetPaths::new(&repo).claude_target_dir().exists()); + + let leftover_staging = fs::read_dir(&repo) + .expect("read repo root") + .filter_map(std::result::Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-setup-staging-") + }); + assert!(!leftover_staging, "staging directory should be cleaned up"); + + let _ = fs::remove_dir_all(&repo); + } +} diff --git a/cli/src/adapters/outbound/filesystem/mod.rs b/cli/src/adapters/outbound/filesystem/mod.rs index 50a2a568..e0ce28bf 100644 --- a/cli/src/adapters/outbound/filesystem/mod.rs +++ b/cli/src/adapters/outbound/filesystem/mod.rs @@ -1,3 +1,4 @@ //! Filesystem-backed outbound adapters. pub(crate) mod context_store; +pub(crate) mod integration_installer; diff --git a/cli/src/adapters/outbound/mod.rs b/cli/src/adapters/outbound/mod.rs index e803d8ec..8f5ff6de 100644 --- a/cli/src/adapters/outbound/mod.rs +++ b/cli/src/adapters/outbound/mod.rs @@ -1,4 +1,5 @@ //! Outbound adapters: implementations of application ports (storage, network, //! and other infrastructure integrations). +pub(crate) mod assets; pub(crate) mod filesystem; diff --git a/cli/src/application/ports/integration_asset_catalog.rs b/cli/src/application/ports/integration_asset_catalog.rs new file mode 100644 index 00000000..26f9627f --- /dev/null +++ b/cli/src/application/ports/integration_asset_catalog.rs @@ -0,0 +1,18 @@ +//! `IntegrationAssetCatalog` port: resolves the embedded assets to install +//! for an integration target, owned by an outbound adapter and consumed by +//! the `InstallIntegrationAssets` use case. + +use crate::domain::integration::{IntegrationAsset, IntegrationTarget}; + +/// Resolves the embedded assets to install for a concrete integration +/// target, filtered by the caller's selected optional workflows. +#[allow(dead_code)] // consumed starting with the InstallIntegrationAssets use case (T03) +pub(crate) trait IntegrationAssetCatalog { + type Error; + + fn assets_for( + &self, + target: IntegrationTarget, + optional_workflows: &[String], + ) -> Result, Self::Error>; +} diff --git a/cli/src/application/ports/integration_installer.rs b/cli/src/application/ports/integration_installer.rs new file mode 100644 index 00000000..4169e43f --- /dev/null +++ b/cli/src/application/ports/integration_installer.rs @@ -0,0 +1,31 @@ +//! `IntegrationInstaller` port: installs resolved integration assets into a +//! repository, owned by an outbound adapter and consumed by the +//! `InstallIntegrationAssets` use case. + +use std::path::{Path, PathBuf}; + +use crate::domain::integration::{IntegrationAsset, IntegrationTarget}; + +/// The outcome of installing assets for a single concrete integration +/// target. +#[derive(Clone, Debug, Eq, PartialEq)] +#[allow(dead_code)] // consumed starting with the InstallIntegrationAssets use case (T03) +pub(crate) struct InstalledIntegrationTarget { + pub(crate) target: IntegrationTarget, + pub(crate) destination_root: PathBuf, + pub(crate) installed_file_count: usize, +} + +/// Installs resolved integration assets for a concrete target into a +/// repository. +#[allow(dead_code)] // consumed starting with the InstallIntegrationAssets use case (T03) +pub(crate) trait IntegrationInstaller { + type Error; + + fn install( + &self, + repository_root: &Path, + target: IntegrationTarget, + assets: &[IntegrationAsset], + ) -> Result; +} diff --git a/cli/src/application/ports/mod.rs b/cli/src/application/ports/mod.rs index f2da15e2..0772b1b8 100644 --- a/cli/src/application/ports/mod.rs +++ b/cli/src/application/ports/mod.rs @@ -1,3 +1,5 @@ //! Ports: interfaces the application layer depends on and adapters implement. pub(crate) mod context_store; +pub(crate) mod integration_asset_catalog; +pub(crate) mod integration_installer; diff --git a/cli/src/application/use_cases/install_integration_assets.rs b/cli/src/application/use_cases/install_integration_assets.rs new file mode 100644 index 00000000..38c38052 --- /dev/null +++ b/cli/src/application/use_cases/install_integration_assets.rs @@ -0,0 +1,231 @@ +//! `InstallIntegrationAssets` use case: orchestrates `IntegrationAssetCatalog` +//! and `IntegrationInstaller` over an expanded target selection. + +use std::path::Path; + +use crate::application::ports::integration_asset_catalog::IntegrationAssetCatalog; +use crate::application::ports::integration_installer::{ + InstalledIntegrationTarget, IntegrationInstaller, +}; +use crate::domain::integration::IntegrationTargetSelection; + +/// The outcome of installing integration assets for an expanded target +/// selection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InstallIntegrationAssetsReport { + pub(crate) targets: Vec, +} + +/// Either collaborator's failure, surfaced without losing which port failed. +#[derive(Debug)] +pub(crate) enum InstallIntegrationAssetsError { + Catalog(CE), + Installer(IE), +} + +/// Installs the embedded assets for an expanded integration target +/// selection, via injected `IntegrationAssetCatalog` and +/// `IntegrationInstaller` collaborators. +pub(crate) struct InstallIntegrationAssets { + catalog: C, + installer: I, +} + +impl InstallIntegrationAssets { + pub(crate) fn new(catalog: C, installer: I) -> Self { + Self { catalog, installer } + } + + pub(crate) fn execute( + &self, + repository_root: &Path, + selection: IntegrationTargetSelection, + optional_workflows: &[String], + ) -> Result> + { + let mut targets = Vec::new(); + + for &target in selection.targets() { + let assets = self + .catalog + .assets_for(target, optional_workflows) + .map_err(InstallIntegrationAssetsError::Catalog)?; + + let installed = self + .installer + .install(repository_root, target, &assets) + .map_err(InstallIntegrationAssetsError::Installer)?; + + targets.push(installed); + } + + Ok(InstallIntegrationAssetsReport { targets }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::path::PathBuf; + + use crate::domain::integration::{IntegrationAsset, IntegrationTarget}; + + #[derive(Default)] + struct FakeCatalog { + calls: RefCell)>>, + fail_on: Option, + } + + impl IntegrationAssetCatalog for FakeCatalog { + type Error = &'static str; + + fn assets_for( + &self, + target: IntegrationTarget, + optional_workflows: &[String], + ) -> Result, Self::Error> { + self.calls + .borrow_mut() + .push((target, optional_workflows.to_vec())); + + if self.fail_on == Some(target) { + return Err("catalog failed"); + } + + Ok(vec![IntegrationAsset { + relative_path: "file.txt".to_string(), + bytes: b"content", + }]) + } + } + + #[derive(Default)] + struct FakeInstaller { + calls: RefCell)>>, + } + + impl IntegrationInstaller for FakeInstaller { + type Error = &'static str; + + fn install( + &self, + repository_root: &Path, + target: IntegrationTarget, + assets: &[IntegrationAsset], + ) -> Result { + self.calls + .borrow_mut() + .push((repository_root.to_path_buf(), target, assets.to_vec())); + + Ok(InstalledIntegrationTarget { + target, + destination_root: repository_root.join("dest"), + installed_file_count: assets.len(), + }) + } + } + + #[test] + fn one_selection_invokes_both_ports_once_with_that_target() { + let catalog = FakeCatalog::default(); + let installer = FakeInstaller::default(); + let use_case = InstallIntegrationAssets::new(catalog, installer); + let repository_root = PathBuf::from("/repo"); + let optional_workflows = vec!["research".to_string()]; + + let report = use_case + .execute( + &repository_root, + IntegrationTargetSelection::One(IntegrationTarget::Claude), + &optional_workflows, + ) + .unwrap(); + + assert_eq!(report.targets.len(), 1); + assert_eq!(report.targets[0].target, IntegrationTarget::Claude); + + let catalog_calls = use_case.catalog.calls.borrow(); + assert_eq!(catalog_calls.len(), 1); + assert_eq!(catalog_calls[0].0, IntegrationTarget::Claude); + assert_eq!(catalog_calls[0].1, optional_workflows); + + let installer_calls = use_case.installer.calls.borrow(); + assert_eq!(installer_calls.len(), 1); + assert_eq!(installer_calls[0].0, repository_root); + assert_eq!(installer_calls[0].1, IntegrationTarget::Claude); + } + + #[test] + fn all_selection_invokes_both_ports_three_times_in_order() { + let catalog = FakeCatalog::default(); + let installer = FakeInstaller::default(); + let use_case = InstallIntegrationAssets::new(catalog, installer); + let repository_root = PathBuf::from("/repo"); + + let report = use_case + .execute(&repository_root, IntegrationTargetSelection::All, &[]) + .unwrap(); + + assert_eq!(report.targets.len(), 3); + + let catalog_calls = use_case.catalog.calls.borrow(); + let catalog_order: Vec = + catalog_calls.iter().map(|(target, _)| *target).collect(); + assert_eq!( + catalog_order, + vec![ + IntegrationTarget::OpenCode, + IntegrationTarget::Claude, + IntegrationTarget::Pi, + ] + ); + + let installer_calls = use_case.installer.calls.borrow(); + let installer_order: Vec = installer_calls + .iter() + .map(|(_, target, _)| *target) + .collect(); + assert_eq!( + installer_order, + vec![ + IntegrationTarget::OpenCode, + IntegrationTarget::Claude, + IntegrationTarget::Pi, + ] + ); + } + + #[test] + fn catalog_error_short_circuits_before_installer_and_later_targets() { + let catalog = FakeCatalog { + calls: RefCell::new(Vec::new()), + fail_on: Some(IntegrationTarget::Claude), + }; + let installer = FakeInstaller::default(); + let use_case = InstallIntegrationAssets::new(catalog, installer); + let repository_root = PathBuf::from("/repo"); + + let result = use_case.execute(&repository_root, IntegrationTargetSelection::All, &[]); + + assert!(matches!( + result, + Err(InstallIntegrationAssetsError::Catalog("catalog failed")) + )); + + let catalog_calls = use_case.catalog.calls.borrow(); + let catalog_order: Vec = + catalog_calls.iter().map(|(target, _)| *target).collect(); + assert_eq!( + catalog_order, + vec![IntegrationTarget::OpenCode, IntegrationTarget::Claude] + ); + + let installer_calls = use_case.installer.calls.borrow(); + let installer_order: Vec = installer_calls + .iter() + .map(|(_, target, _)| *target) + .collect(); + assert_eq!(installer_order, vec![IntegrationTarget::OpenCode]); + } +} diff --git a/cli/src/application/use_cases/mod.rs b/cli/src/application/use_cases/mod.rs index 5871a398..d339cc98 100644 --- a/cli/src/application/use_cases/mod.rs +++ b/cli/src/application/use_cases/mod.rs @@ -1,3 +1,4 @@ //! Use cases: application-specific orchestration of domain and ports. pub(crate) mod ensure_context_baseline; +pub(crate) mod install_integration_assets; diff --git a/cli/src/domain/integration/asset.rs b/cli/src/domain/integration/asset.rs new file mode 100644 index 00000000..9f9794d3 --- /dev/null +++ b/cli/src/domain/integration/asset.rs @@ -0,0 +1,10 @@ +//! A single embedded integration asset to be installed into a repository. + +/// An embedded asset destined for a repository-relative path within an +/// integration target's install root. +#[derive(Clone, Debug, Eq, PartialEq)] +#[allow(dead_code)] // consumed starting with the IntegrationAssetCatalog port (T02) +pub(crate) struct IntegrationAsset { + pub(crate) relative_path: String, + pub(crate) bytes: &'static [u8], +} diff --git a/cli/src/domain/integration/mod.rs b/cli/src/domain/integration/mod.rs new file mode 100644 index 00000000..33ac7473 --- /dev/null +++ b/cli/src/domain/integration/mod.rs @@ -0,0 +1,11 @@ +//! Integration-asset domain model: install targets, target selection, and +//! the embedded assets installed for them. + +mod asset; +mod target; + +#[allow(unused_imports)] // consumed starting with the IntegrationAssetCatalog port (T02) +pub(crate) use asset::IntegrationAsset; +#[allow(unused_imports)] +// consumed starting with the IntegrationAssetCatalog/IntegrationInstaller ports (T02) +pub(crate) use target::{IntegrationTarget, IntegrationTargetSelection}; diff --git a/cli/src/domain/integration/target.rs b/cli/src/domain/integration/target.rs new file mode 100644 index 00000000..4ed5ac7b --- /dev/null +++ b/cli/src/domain/integration/target.rs @@ -0,0 +1,69 @@ +//! Integration targets and target selection for embedded-asset installation. + +/// A concrete integration target an outbound adapter may install assets for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] // consumed starting with the IntegrationAssetCatalog/IntegrationInstaller ports (T02) +pub(crate) enum IntegrationTarget { + OpenCode, + Claude, + Pi, +} + +const ALL_TARGETS: [IntegrationTarget; 3] = [ + IntegrationTarget::OpenCode, + IntegrationTarget::Claude, + IntegrationTarget::Pi, +]; + +/// A caller's target selection: a single target, or all of them. +/// +/// `All` is expanded into concrete `IntegrationTarget` values via +/// [`IntegrationTargetSelection::targets`] before any application port is +/// invoked, so no outbound adapter is ever called with a meta value +/// representing "all targets". +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] // consumed starting with the IntegrationAssetCatalog/IntegrationInstaller ports (T02) +pub(crate) enum IntegrationTargetSelection { + One(IntegrationTarget), + All, +} + +impl IntegrationTargetSelection { + /// The concrete targets this selection expands to. + /// + /// `One` yields the single wrapped target; `All` yields + /// `[OpenCode, Claude, Pi]` in that order. + #[allow(dead_code)] // consumed starting with the InstallIntegrationAssets use case (T03) + pub(crate) fn targets(&self) -> &[IntegrationTarget] { + match self { + Self::One(target) => std::slice::from_ref(target), + Self::All => &ALL_TARGETS, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_selection_targets_wraps_the_single_target() { + let selection = IntegrationTargetSelection::One(IntegrationTarget::Claude); + + assert_eq!(selection.targets(), &[IntegrationTarget::Claude]); + } + + #[test] + fn all_selection_targets_returns_every_target_in_order() { + let selection = IntegrationTargetSelection::All; + + assert_eq!( + selection.targets(), + &[ + IntegrationTarget::OpenCode, + IntegrationTarget::Claude, + IntegrationTarget::Pi, + ] + ); + } +} diff --git a/cli/src/domain/mod.rs b/cli/src/domain/mod.rs index 52b10ada..e54893da 100644 --- a/cli/src/domain/mod.rs +++ b/cli/src/domain/mod.rs @@ -6,3 +6,4 @@ //! `context/architecture.md` for the full dependency-direction rules. pub(crate) mod context; +pub(crate) mod integration; diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 16aab0d4..4ca5beb8 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -590,7 +590,55 @@ pub fn install_embedded_setup_assets( target: SetupTarget, selected_optional_workflows: &[String], ) -> Result { - install::install_embedded_setup_assets(repository_root, target, selected_optional_workflows) + use crate::adapters::outbound::assets::embedded_integration_assets::EmbeddedIntegrationAssetCatalog; + use crate::adapters::outbound::filesystem::integration_installer::FilesystemIntegrationInstaller; + use crate::application::use_cases::install_integration_assets::{ + InstallIntegrationAssets, InstallIntegrationAssetsError, + }; + use crate::domain::integration::{IntegrationTarget, IntegrationTargetSelection}; + + let selection = match target { + SetupTarget::OpenCode => IntegrationTargetSelection::One(IntegrationTarget::OpenCode), + SetupTarget::Claude => IntegrationTargetSelection::One(IntegrationTarget::Claude), + SetupTarget::Pi => IntegrationTargetSelection::One(IntegrationTarget::Pi), + SetupTarget::All => IntegrationTargetSelection::All, + }; + + let use_case = InstallIntegrationAssets::new( + EmbeddedIntegrationAssetCatalog, + FilesystemIntegrationInstaller, + ); + + let report = use_case + .execute(repository_root, selection, selected_optional_workflows) + .map_err(|error| match error { + InstallIntegrationAssetsError::Catalog(never) => match never {}, + InstallIntegrationAssetsError::Installer(error) => error, + })?; + + let target_results = report + .targets + .into_iter() + .map(|installed| SetupInstallTargetResult { + target: setup_target_for_integration_target(installed.target), + destination_root: installed.destination_root, + installed_file_count: installed.installed_file_count, + }) + .collect(); + + Ok(SetupInstallOutcome { target_results }) +} + +fn setup_target_for_integration_target( + target: crate::domain::integration::IntegrationTarget, +) -> SetupTarget { + use crate::domain::integration::IntegrationTarget; + + match target { + IntegrationTarget::OpenCode => SetupTarget::OpenCode, + IntegrationTarget::Claude => SetupTarget::Claude, + IntegrationTarget::Pi => SetupTarget::Pi, + } } pub(crate) fn setup_install_recovery_guidance( @@ -743,15 +791,12 @@ mod install { time::{SystemTime, UNIX_EPOCH}, }; - use crate::services::default_paths::InstallTargetPaths; use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; use super::{ - cleanup_path_if_exists, concrete_targets_for, hook_install_recovery_guidance, - iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, - setup_install_recovery_guidance, EmbeddedAsset, RequiredHookInstallResult, - RequiredHookInstallStatus, RequiredHooksInstallOutcome, SetupInstallOutcome, - SetupInstallTargetResult, SetupTarget, + cleanup_path_if_exists, hook_install_recovery_guidance, iter_required_hook_assets, + EmbeddedAsset, RequiredHookInstallResult, RequiredHookInstallStatus, + RequiredHooksInstallOutcome, }; pub(super) fn prepare_setup_hooks_repository(repository_root: &Path) -> Result { @@ -772,19 +817,6 @@ mod install { }) } - pub(super) fn install_embedded_setup_assets( - repository_root: &Path, - target: SetupTarget, - selected_optional_workflows: &[String], - ) -> Result { - install_embedded_setup_assets_with_rename( - repository_root, - target, - selected_optional_workflows, - |from, to| fs::rename(from, to), - ) - } - fn install_required_git_hooks_in_resolved_repository( resolved_repository_root: &Path, mut rename_fn: F, @@ -827,7 +859,7 @@ mod install { where F: FnMut(&Path, &Path) -> io::Result<()>, { - validate_embedded_relative_path(hook_asset.relative_path)?; + validate_hook_relative_path(hook_asset.relative_path)?; let hook_path = hooks_directory.join(hook_asset.relative_path); let existing_metadata = fs::metadata(&hook_path).ok(); @@ -881,7 +913,7 @@ mod install { }); } - remove_existing_install_target(&hook_path).with_context(|| { + remove_existing_hook_target(&hook_path).with_context(|| { format!( "Failed to replace existing hook '{}' without creating a backup", hook_path.display() @@ -1102,92 +1134,7 @@ mod install { Ok(metadata.is_file()) } - fn install_embedded_setup_assets_with_rename( - repository_root: &Path, - target: SetupTarget, - selected_optional_workflows: &[String], - mut rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - ensure_directory_is_writable(repository_root, "setup repository root")?; - - let mut target_results = Vec::new(); - - for concrete_target in concrete_targets_for(target) { - let concrete_target = *concrete_target; - let assets: Vec<&'static EmbeddedAsset> = - iter_embedded_assets_for_setup_target_with_selection( - concrete_target, - selected_optional_workflows, - ) - .collect(); - let result = install_assets_for_concrete_target_with_rename( - repository_root, - concrete_target, - &assets, - &mut rename_fn, - )?; - target_results.push(result); - } - - Ok(SetupInstallOutcome { target_results }) - } - - fn install_assets_for_concrete_target_with_rename( - repository_root: &Path, - target: SetupTarget, - assets: &[&'static EmbeddedAsset], - rename_fn: &mut F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } - }; - let staging_root = create_staging_root(repository_root, target)?; - - if let Err(error) = write_assets_to_staging(&staging_root, assets) { - cleanup_path_if_exists(&staging_root); - return Err(error); - } - - if destination_root.exists() { - remove_existing_install_target(&destination_root).with_context(|| { - format!( - "Failed to replace existing setup target '{}' without creating a backup", - destination_root.display() - ) - })?; - } - - if let Err(error) = rename_fn(&staging_root, &destination_root).with_context(|| { - format!( - "Failed to swap staged install '{}' into destination '{}'", - staging_root.display(), - destination_root.display() - ) - }) { - cleanup_path_if_exists(&staging_root); - return Err(error.context(setup_install_recovery_guidance(target, &destination_root))); - } - - Ok(SetupInstallTargetResult { - target, - destination_root, - installed_file_count: assets.len(), - }) - } - - fn remove_existing_install_target(destination_root: &Path) -> Result<()> { + fn remove_existing_hook_target(destination_root: &Path) -> Result<()> { let metadata = fs::metadata(destination_root).with_context(|| { format!( "Failed to inspect existing setup target '{}'", @@ -1214,36 +1161,7 @@ mod install { Ok(()) } - fn write_assets_to_staging( - staging_root: &Path, - assets: &[&'static EmbeddedAsset], - ) -> Result<()> { - for asset in assets { - validate_embedded_relative_path(asset.relative_path)?; - let destination = staging_root.join(asset.relative_path); - let parent = destination - .parent() - .context("Embedded asset destination should have a parent directory")?; - - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create staged parent directory '{}'", - parent.display() - ) - })?; - - fs::write(&destination, asset.bytes).with_context(|| { - format!( - "Failed to write staged embedded asset '{}'", - destination.display() - ) - })?; - } - - Ok(()) - } - - fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { + fn validate_hook_relative_path(relative_path: &str) -> Result<()> { let path = Path::new(relative_path); if path.is_absolute() { @@ -1261,52 +1179,6 @@ mod install { Ok(()) } - - fn create_staging_root(repository_root: &Path, target: SetupTarget) -> Result { - let install_targets = InstallTargetPaths::new(repository_root); - let target_dir = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } - }; - let target_label = target_dir - .file_name() - .and_then(|name| name.to_str()) - .context("Setup target directory should have a valid UTF-8 file name")? - .trim_start_matches('.'); - let epoch_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX_EPOCH")? - .as_nanos(); - - for attempt in 0..1000_u16 { - let candidate = repository_root.join(format!( - ".sce-setup-staging-{target_label}-{epoch_nanos}-{}-{attempt}", - std::process::id() - )); - - match fs::create_dir(&candidate) { - Ok(()) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error).with_context(|| { - format!( - "Failed to create staging directory '{}'", - candidate.display() - ) - }); - } - } - } - - bail!( - "Could not allocate a unique staging directory under '{}'", - repository_root.display() - ) - } } pub trait SetupTargetPrompter { diff --git a/context/architecture.md b/context/architecture.md index 0098ce3b..36eca4e4 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -152,6 +152,38 @@ providers still flow through `app::run`/`services::setup` unchanged. See `context/sce/setup-repo-local-config-bootstrap.md` for the full behavior contract this slice preserves. +The second landed slice is `sce setup`'s embedded integration-asset +installation. `cli/src/domain/integration/{target,asset}.rs` defines +`IntegrationTarget` (`OpenCode`/`Claude`/`Pi`), `IntegrationTargetSelection` +(`One(IntegrationTarget)` / `All`, with `targets()` expanding `All` into +`[OpenCode, Claude, Pi]`), and `IntegrationAsset`. +`cli/src/application/ports/{integration_asset_catalog,integration_installer}.rs` +define the `IntegrationAssetCatalog` and `IntegrationInstaller` ports, and +`cli/src/application/use_cases/install_integration_assets.rs`'s +`InstallIntegrationAssets` use case expands a selection into concrete targets +and calls both ports once per target, short-circuiting on the first error. +`cli/src/adapters/outbound/assets/embedded_integration_assets.rs`'s +`EmbeddedIntegrationAssetCatalog` implements the catalog port by delegating to +`services::setup::iter_embedded_assets_for_setup_target_with_selection`, and +`cli/src/adapters/outbound/filesystem/integration_installer.rs`'s +`FilesystemIntegrationInstaller` implements the installer port, owning +staging, write, existing-target removal (no backup), rename/swap, and +cleanup-with-recovery-guidance for embedded assets. This slice deliberately +keeps `IntegrationTargetSelection::All` unreachable at the adapter boundary: +it is expanded via `targets()` inside the use case before either port is ever +invoked, so no outbound adapter can be called with a meta "all targets" +value. `services::setup::install_embedded_setup_assets` is now a +compatibility facade: it converts `SetupTarget` into +`IntegrationTargetSelection`, constructs both adapters, runs the use case, +and maps the result back into +`SetupInstallOutcome`/`SetupInstallTargetResult` with unchanged output. +Required-hook installation is a separate, unmigrated concern: the inline +`services::setup::install` module keeps its own hook-scoped staging/removal +helpers (`remove_existing_hook_target`, `validate_hook_relative_path`) rather +than sharing the ones this slice moved into the filesystem adapter, so hook +installation behavior is untouched by this migration. `composition::run` is +not yet wired through this slice either. + ```mermaid flowchart LR subgraph adapters["adapters"] @@ -226,7 +258,7 @@ the `cli-architecture` check. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering. Active hook runtime, setup/lifecycle storage, and trace status/list/shell flows resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator stages embedded files and uses a unified remove-and-replace policy (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure and no backup artifact creation), and formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same remove-and-replace policy (removing existing hooks before swapping staged content, with deterministic recovery guidance on swap failure). After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. -- `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. +- `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization and required-hook installation (including its own hook-scoped staging/swap and filesystem safety guards), while the inline `prompt` module owns interactive target selection and prompt styling. Embedded integration-asset staging/swap install behavior moved out of this module into the `InstallIntegrationAssets` vertical slice (`cli/src/adapters/outbound/filesystem/integration_installer.rs`); `install_embedded_setup_assets` in this file is a facade over that slice. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Checkout DB discovery no longer lives in `doctor`; it moved to the `trace` group (`sce trace db list`) in `cli/src/services/trace/`. Report fact collection preserves environment/repository/hook/integration display data and checkout identity facts, while service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. diff --git a/context/glossary.md b/context/glossary.md index d4e701e4..19022174 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -159,8 +159,13 @@ - `sce policy command adapter`: Hidden/internal `sce policy bash` command in `cli/src/services/bash_policy.rs` that exposes the Rust bash-policy evaluator to hook callers. It reads JSON from STDIN, resolves bash-policy config from the project root (git root with current-directory fallback), evaluates the command against active policies, and emits hook-safe output: Claude Code deny JSON (`hookSpecificOutput` with `permissionDecision: "deny"`) or empty string for allowed commands in `--output claude-hook` mode (default), and structured `{"status","decision","command","normalized_argv","reason","policy_id"}` JSON in `--output json` mode. Input modes are `--input claude-pre-tool-use` (default, parses Claude `PreToolUse` event JSON with `tool_name`/`tool_input.command`) and `--input normalized` (parses `{"command":...}` for OpenCode delegation). The command uses explicit `--input`/`--output` flags rather than auto-detection; Claude Code hooks invoke `sce policy bash` with defaults, while OpenCode plugin delegation passes `--input normalized --output json`. Invalid invocation/input returns deterministic validation diagnostics without executing target commands. - `bash policy redundancy warning`: Non-fatal config validation output emitted when `forbid-git-all` and `forbid-git-commit` are enabled together; the config remains valid, but `sce config show|validate` reports the overlap deterministically as a warning instead of an error. - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. -- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. -- `setup remove-and-replace`: Replacement choreography in `cli/src/services/setup/mod.rs` where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. +- `setup install engine`: `services::setup::install_embedded_setup_assets` (`cli/src/services/setup/mod.rs`) is now a thin compatibility facade over the CLI's internal hexagonal layers — see `IntegrationTarget`, `IntegrationTargetSelection`, `IntegrationAsset`, `IntegrationAssetCatalog`, `IntegrationInstaller`, `InstallIntegrationAssets`, `EmbeddedIntegrationAssetCatalog`, and `FilesystemIntegrationInstaller` — which writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/`/`.pi/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. +- `setup remove-and-replace`: Replacement choreography (now owned by `FilesystemIntegrationInstaller` for embedded integration assets, and separately by the `install` module's hook-scoped helpers for required-hook installation) where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. +- `IntegrationTarget` / `IntegrationTargetSelection` / `IntegrationAsset`: Domain types in `cli/src/domain/integration/{target,asset}.rs` for embedded-asset installation. `IntegrationTarget` has variants `OpenCode`, `Claude`, `Pi`. `IntegrationTargetSelection` is `One(IntegrationTarget)` or `All`, with `targets()` expanding `One` to its single target and `All` to `[OpenCode, Claude, Pi]` in that order — expansion happens before any application port is invoked, so no outbound adapter is ever called with a meta "all targets" value. `IntegrationAsset` carries `relative_path: String` and `bytes: &'static [u8]`. +- `IntegrationAssetCatalog` / `IntegrationInstaller`: Application ports in `cli/src/application/ports/{integration_asset_catalog,integration_installer}.rs`. `IntegrationAssetCatalog::assets_for(target, optional_workflows)` resolves the embedded assets for one concrete target. `IntegrationInstaller::install(repository_root, target, assets)` installs them and returns `InstalledIntegrationTarget` (`target`, `destination_root`, `installed_file_count`). +- `InstallIntegrationAssets`: Application use case in `cli/src/application/use_cases/install_integration_assets.rs` that expands a `IntegrationTargetSelection` via `targets()`, then for each concrete target calls `IntegrationAssetCatalog::assets_for` followed by `IntegrationInstaller::install`, short-circuiting on the first port error (`InstallIntegrationAssetsError::{Catalog, Installer}`) and returning an `InstallIntegrationAssetsReport`. Generic over both port types, with no dependency on `crate::services` or `crate::adapters`. +- `EmbeddedIntegrationAssetCatalog`: Outbound adapter in `cli/src/adapters/outbound/assets/embedded_integration_assets.rs` implementing `IntegrationAssetCatalog` by delegating to `services::setup::iter_embedded_assets_for_setup_target_with_selection` and converting each `EmbeddedAsset` into a domain `IntegrationAsset`. +- `FilesystemIntegrationInstaller`: Outbound adapter in `cli/src/adapters/outbound/filesystem/integration_installer.rs` implementing `IntegrationInstaller`: stages assets into a unique staging directory, rejects absolute or `..`-containing relative paths before writing, removes an existing destination without a backup, renames staging into place, and on staging-write or rename failure cleans up the staging path and returns the existing recovery-guidance text. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `conversation-trace` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id` values plus direct `model_id` and `tool_version` values (session-model fallback was removed in the `remove-session-models-direct-claude-model-id` plan). - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command`: `sce sync` has no command wiring and no `cli/src/services/sync.rs` module in the current runtime. Local DB initialization and health ownership are split between setup and doctor instead. diff --git a/context/plans/install-integration-assets.md b/context/plans/install-integration-assets.md new file mode 100644 index 00000000..b6e4f4d5 --- /dev/null +++ b/context/plans/install-integration-assets.md @@ -0,0 +1,183 @@ +# Plan: install-integration-assets + +## Change summary + +Migrate `install_embedded_setup_assets(repository_root, target, selected_optional_workflows)` +into the CLI's internal hexagonal architecture as the second landed vertical +slice, following the pattern established by the context-baseline slice +(`context/plans/migrate-context-baseline-vertical-slice.md`, now folded into +`context/architecture.md`). + +`run_setup_for_mode` today combines optional-workflow selection, embedded +asset installation, config persistence, and CLI rendering in one module that +also owns Git repository discovery, hook installation, filesystem staging, +process execution, and prompting. This plan extracts only the embedded-asset +installation capability: a pure domain model for integration targets and +assets, two application ports (an asset catalog and an installer), one use +case orchestrating them, and two outbound adapters (an embedded-asset catalog +wrapping the existing generated catalog, and a filesystem installer owning +staging/replace/rename/cleanup). `services::setup::install_embedded_setup_assets` +becomes a thin compatibility facade over the new layers, preserving its +public signature and every current side effect and output byte-for-byte. +`run_setup_for_mode`, config persistence, hook installation, repository +discovery, and interactive prompting are unchanged and stay in +`services::setup`. + +The domain model also fixes a known modeling defect: `SetupTarget::All` +currently reaches code paths where it is invalid, producing `unreachable!` +branches. The new domain types separate a caller's selection +(`IntegrationTargetSelection::{One, All}`) from the concrete targets an +outbound adapter may receive (`IntegrationTarget::{OpenCode, Claude, Pi}`), +so `All` is expanded before any port is invoked and outbound adapters are +typed to make an `All` value impossible to pass them. + +## Acceptance criteria + +- [x] AC1: `IntegrationTargetSelection::All` is expanded into concrete `IntegrationTarget` values before either application port is invoked; no outbound adapter's trait method can be called with a value representing "all targets". + - Validate: `cargo test --manifest-path cli/Cargo.toml install_integration_assets` (use case tests assert `IntegrationAssetCatalog`/`IntegrationInstaller` are invoked once per concrete target, never with a meta value); inspect `IntegrationAssetCatalog::assets_for` and `IntegrationInstaller::install` signatures take `IntegrationTarget`, not `IntegrationTargetSelection`. +- [x] AC2: The `InstallIntegrationAssets` application use case imports neither `crate::services` nor any filesystem API. + - Validate: `scripts/check-cli-architecture.sh` (run via `nix flake check` `cli-architecture` check) against `cli/src/application/**`. +- [x] AC3: All staging, write, existing-target removal, rename/swap, and cleanup I/O lives in the filesystem outbound adapter; the use case and domain model contain none of it. + - Validate: `cargo test --manifest-path cli/Cargo.toml filesystem::integration_installer` (adapter-level staging/replace/cleanup tests); code inspection confirms `cli/src/application/use_cases/install_integration_assets.rs` and `cli/src/domain/integration/**` contain no `std::fs`/staging logic. +- [x] AC4: Optional-workflow asset filtering remains byte-for-byte behavior compatible with the current `iter_embedded_assets_for_setup_target_with_selection` filter. + - Validate: `cargo test --manifest-path cli/Cargo.toml embedded_integration_assets` (adapter delegates to the existing function and returns the same paths/bytes for representative target + selection combinations). +- [x] AC5: `sce setup --opencode|--claude|--pi|--all --non-interactive` install the same files, at the same destination paths, with the same `installed_file_count`, as before this migration. + - Validate: existing `cli/src/services/setup/mod.rs` setup-install tests continue to pass unmodified through the facade; manual smoke run of each target flag in a scratch repository. +- [x] AC6: An existing target directory is replaced using the current no-backup remove-then-rename policy, with no backup artifact created. + - Validate: existing replace-existing-target test continues to pass against the new filesystem adapter. +- [x] AC7: Embedded asset paths that are absolute or contain a `..` component are rejected before being written to staging. + - Validate: existing invalid-embedded-path rejection test continues to pass against the new filesystem adapter. +- [x] AC8: A failed staging write or a failed rename cleans up the temporary staging path and surfaces the existing recovery guidance text. + - Validate: existing rename-failure test (injected failing rename function) continues to pass against the new filesystem adapter. +- [x] AC9: `persist_integration_targets` still runs only after `install_embedded_setup_assets` returns successfully, because `run_setup_for_mode`'s call order is unchanged. + - Validate: inspect `run_setup_for_mode` in `cli/src/services/setup/command.rs` (or wherever it currently lives) shows no reordering; existing setup-command tests covering persistence-after-install continue to pass. +- [x] AC10: Setup success/error message text and process exit codes are unchanged for every target. + - Validate: existing message-formatting tests (`format_setup_install_success_message` and friends) pass unmodified; manual `sce setup --claude --non-interactive` output diffed against a pre-change run. +- [x] AC11: The legacy asset-installation implementation is removed from the inline `services::setup::install` module; only hook-installation code remains there. + - Validate: `grep -nE "fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)" cli/src/services/setup/mod.rs` returns no matches. + +### Full validation + +- `nix flake check` +- `nix run .#pkl-check-generated` + +### Context sync + +- `context/architecture.md` — extend the "CLI internal hexagonal architecture" section with this second landed slice (integration asset installation), mirroring how the context-baseline slice is documented there. + +## Constraints and non-goals + +- **In scope:** `cli/src/domain/integration/{mod,target,asset}.rs`; `cli/src/application/ports/{integration_asset_catalog,integration_installer}.rs`; `cli/src/application/use_cases/install_integration_assets.rs`; `cli/src/adapters/outbound/assets/{mod,embedded_integration_assets}.rs`; `cli/src/adapters/outbound/filesystem/integration_installer.rs`; the `install_embedded_setup_assets` facade and the inline `install` module's asset-install functions in `cli/src/services/setup/mod.rs`; the module-registration files (`domain/mod.rs`, `application/ports/mod.rs`, `application/use_cases/mod.rs`, `adapters/outbound/mod.rs`, `adapters/outbound/filesystem/mod.rs`). +- **Out of scope:** `run_setup_for_mode`, `persist_integration_targets`, `persisted_optional_workflows`, `SetupCommand`, `SetupTargetPrompter`, Git hook installation (`install_required_git_hooks` and everything it depends on), repository discovery, and `composition::run` wiring (this slice, like the context-baseline slice before it, does not wire `composition::run`; `install_embedded_setup_assets` keeps its current call sites unchanged). +- **Constraints:** Preserve current installed files, destination paths, message text, error text, and exit codes exactly. The embedded-asset outbound adapter may depend on `services::setup`'s existing generated catalog (`iter_embedded_assets_for_setup_target_with_selection`, `EmbeddedAsset`, `OPTIONAL_WORKFLOWS`) per the architecture decision permitting outbound adapters to depend on `services` during migration; do not force the generated hook and integration catalogs apart. No new crate dependencies. +- **Non-goal:** Splitting the generated hook and integration asset catalogs apart. Migrating `persist_integration_targets` into a `RepositoryConfigStore` port (explicitly the next planned slice, not this one). Wiring `composition::run` through this slice. + +## Assumptions + +- `IntegrationAsset` holds `bytes: &'static [u8]` rather than an owned `Vec`, since the underlying embedded assets are build-time-generated `'static` byte slices and copying them on every install would be pure overhead with no behavior change; this keeps the domain type plain data with no infrastructure dependency. +- The `InstallIntegrationAssets` use case fails fast on the first port error per target, matching the legacy loop's `?`-per-target behavior (installation does not continue past the first failing target), so AC10's unchanged-error-text guarantee holds without new partial-failure semantics. +- Two application ports with distinct associated `Error` types are combined behind a small local `InstallIntegrationAssetsError` enum defined in the use-case module, extending the single-port pattern `EnsureContextBaseline` established (`S::Error` passthrough) to two collaborating ports. + +## Task stack + +- [x] T01: `Add the domain integration target/asset model` (status:done) + - Task ID: T01 + - Goal: Define pure domain types `IntegrationTarget`, `IntegrationTargetSelection`, and `IntegrationAsset` with no infrastructure dependency. + - Boundaries (in/out of scope): In — `cli/src/domain/integration/{mod,target,asset}.rs`, registering `pub(crate) mod integration;` in `cli/src/domain/mod.rs`. Out — any application, adapter, or `services` code. + - Dependencies: none + - Done when: `IntegrationTarget` has variants `OpenCode`, `Claude`, `Pi`; `IntegrationTargetSelection` has variants `One(IntegrationTarget)` and `All`, with `targets(&self) -> &[IntegrationTarget]` returning the single wrapped target for `One` and `[OpenCode, Claude, Pi]` in that order for `All`; `IntegrationAsset` carries `relative_path: String` and `bytes: &'static [u8]`. + - Verification notes (commands or checks): `cargo test --manifest-path cli/Cargo.toml domain::integration`; `scripts/check-cli-architecture.sh` (no forbidden imports in `cli/src/domain/**`). + - Evidence: Added `cli/src/domain/integration/{mod,target,asset}.rs` and registered `pub(crate) mod integration;` in `cli/src/domain/mod.rs`. `IntegrationTarget` (`OpenCode`/`Claude`/`Pi`), `IntegrationTargetSelection` (`One`/`All` with `targets()`), and `IntegrationAsset` (`relative_path: String`, `bytes: &'static [u8]`) are all pure data with no infrastructure dependency; unused-until-T02/T03 items carry `#[allow(dead_code)]`/`#[allow(unused_imports)]` following the `ContextStore`/`ContextBaselineChanges` pattern. + - Verification: `nix build .#checks.x86_64-linux.cli-tests` — 192 passed, including `domain::integration::target::tests::{one_selection_targets_wraps_the_single_target, all_selection_targets_returns_every_target_in_order}`; `nix build .#checks.x86_64-linux.cli-architecture` — passed, no forbidden imports in `cli/src/domain/**`. + +- [x] T02: `Add the IntegrationAssetCatalog and IntegrationInstaller application ports` (status:done) + - Task ID: T02 + - Goal: Define the two application-owned port traits the use case will depend on. + - Boundaries (in/out of scope): In — `cli/src/application/ports/{integration_asset_catalog,integration_installer}.rs` (the latter also defines `InstalledIntegrationTarget`), registering both in `cli/src/application/ports/mod.rs`. Out — any concrete adapter implementation, the use case itself. + - Dependencies: T01 + - Done when: `IntegrationAssetCatalog::assets_for(&self, target: IntegrationTarget, optional_workflows: &[String]) -> Result, Self::Error>` and `IntegrationInstaller::install(&self, repository_root: &Path, target: IntegrationTarget, assets: &[IntegrationAsset]) -> Result` compile with `#[allow(dead_code)]` pending T03's consumption, following the `ContextStore` port's dead-code-allowance pattern. + - Verification notes (commands or checks): `cargo build --manifest-path cli/Cargo.toml`; `scripts/check-cli-architecture.sh`. + - Evidence: Added `cli/src/application/ports/integration_asset_catalog.rs` (`IntegrationAssetCatalog` trait with `assets_for(&self, target: IntegrationTarget, optional_workflows: &[String]) -> Result, Self::Error>`) and `cli/src/application/ports/integration_installer.rs` (`InstalledIntegrationTarget { target, destination_root, installed_file_count }` mirroring `services::setup::SetupInstallTargetResult`'s shape, and `IntegrationInstaller` trait with `install(&self, repository_root: &Path, target: IntegrationTarget, assets: &[IntegrationAsset]) -> Result`); registered both modules in `cli/src/application/ports/mod.rs`. Both traits carry `#[allow(dead_code)]` pending consumption by the `InstallIntegrationAssets` use case (T03), following the `ContextStore` pattern. + - Verification: `nix build .#checks.x86_64-linux.cli-tests` — passed; `nix build .#checks.x86_64-linux.cli-architecture` — passed, no forbidden imports introduced in `cli/src/application/**`. + +- [x] T03: `Add the InstallIntegrationAssets use case` (status:done) + - Task ID: T03 + - Goal: Orchestrate `IntegrationAssetCatalog` and `IntegrationInstaller` over an expanded target selection, producing `InstallIntegrationAssetsReport { targets: Vec }`. + - Boundaries (in/out of scope): In — `cli/src/application/use_cases/install_integration_assets.rs` (including the local `InstallIntegrationAssetsError` enum and `InstallIntegrationAssetsReport`), registering it in `cli/src/application/use_cases/mod.rs`. Out — any concrete adapter, any `services`/filesystem code. + - Dependencies: T02 + - Done when: `execute(repository_root, selection: IntegrationTargetSelection, optional_workflows: &[String])` calls `selection.targets()`, then for each target calls `catalog.assets_for` followed by `installer.install`, stopping at the first error; unit tests using fake catalog/installer collaborators (mirroring `EnsureContextBaseline`'s `FakeContextStore` pattern) prove: `One(target)` invokes both ports exactly once with that target; `All` invokes both ports exactly three times in `OpenCode, Claude, Pi` order; `optional_workflows` is forwarded verbatim; a catalog error for one target short-circuits before that target's installer call and before any later target. + - Verification notes (commands or checks): `cargo test --manifest-path cli/Cargo.toml install_integration_assets`; `scripts/check-cli-architecture.sh` (confirms no `services`/filesystem imports in the use case). + - Evidence: Added `cli/src/application/use_cases/install_integration_assets.rs`, registered as `pub(crate) mod install_integration_assets;` in `cli/src/application/use_cases/mod.rs`. `InstallIntegrationAssets` wraps a `catalog: C` and `installer: I`; `execute(&self, repository_root: &Path, selection: IntegrationTargetSelection, optional_workflows: &[String])` iterates `selection.targets()`, calling `catalog.assets_for` then `installer.install` per target and returning on the first error via `InstallIntegrationAssetsError::{Catalog, Installer}`, matching `EnsureContextBaseline`'s single-port pattern extended to two collaborators. Unit tests use `FakeCatalog`/`FakeInstaller` collaborators (mirroring `EnsureContextBaseline`'s `FakeContextStore`) and prove: `One` invokes both ports once with that target and forwards `optional_workflows` verbatim; `All` invokes both ports three times in `OpenCode, Claude, Pi` order; a catalog error on `Claude` short-circuits after `OpenCode`'s successful install, before `Claude`'s installer call and before `Pi`. + - Verification: `nix build .#checks.x86_64-linux.cli-tests` — 195 passed, including `application::use_cases::install_integration_assets::tests::{one_selection_invokes_both_ports_once_with_that_target, all_selection_invokes_both_ports_three_times_in_order, catalog_error_short_circuits_before_installer_and_later_targets}`; `nix build .#checks.x86_64-linux.cli-architecture` — passed, no forbidden imports (`crate::services`, `std::fs`, etc.) in `cli/src/application/**`. + +- [x] T04: `Add the embedded-asset catalog outbound adapter` (status:done) + - Task ID: T04 + - Goal: Implement `IntegrationAssetCatalog` by wrapping the existing generated embedded-asset catalog in `services::setup`. + - Boundaries (in/out of scope): In — `cli/src/adapters/outbound/assets/{mod,embedded_integration_assets}.rs`, registering `pub(crate) mod assets;` in `cli/src/adapters/outbound/mod.rs`. Out — the filesystem installer adapter, any use-case or domain change. + - Dependencies: T02 + - Done when: `EmbeddedIntegrationAssetCatalog` maps `IntegrationTarget` to the corresponding concrete `services::setup::SetupTarget` variant, calls `services::setup::iter_embedded_assets_for_setup_target_with_selection`, and converts each `&'static EmbeddedAsset` into a domain `IntegrationAsset` with an identical `relative_path` and `bytes`; a test proves the adapter's output for a representative target + optional-workflow selection matches calling `iter_embedded_assets_for_setup_target_with_selection` directly. + - Verification notes (commands or checks): `cargo test --manifest-path cli/Cargo.toml embedded_integration_assets`. + - Evidence: Added `cli/src/adapters/outbound/assets/{mod,embedded_integration_assets}.rs`, registered `pub(crate) mod assets;` in `cli/src/adapters/outbound/mod.rs`. `EmbeddedIntegrationAssetCatalog` implements `IntegrationAssetCatalog` (`type Error = Infallible`, since the wrapped `services::setup` function is infallible); `setup_target_for` maps each `IntegrationTarget` variant to its `services::setup::SetupTarget` counterpart, and `assets_for` delegates to `iter_embedded_assets_for_setup_target_with_selection`, converting each `&'static EmbeddedAsset` into a domain `IntegrationAsset` with the same `relative_path`/`bytes`. Two tests: one proves the adapter's output for `Claude` + a `research` optional-workflow selection matches calling the wrapped function directly; the other proves the same parity for all three concrete targets with no optional-workflow selection. + - Verification: `nix build .#checks.x86_64-linux.cli-tests` — 197 passed, including `adapters::outbound::assets::embedded_integration_assets::tests::{assets_for_matches_the_underlying_generated_catalog, assets_for_maps_every_concrete_target}`; `nix build .#checks.x86_64-linux.cli-architecture` — passed, no forbidden imports (adapters layer is unrestricted and may depend on `services`). + +- [x] T05: `Add the filesystem integration installer outbound adapter` (status:done) + - Task ID: T05 + - Goal: Move staging, write, existing-target removal, rename/swap, and cleanup-with-recovery-guidance logic out of `services::setup::install` and into a filesystem outbound adapter implementing `IntegrationInstaller`. + - Boundaries (in/out of scope): In — `cli/src/adapters/outbound/filesystem/integration_installer.rs`, registering `pub(crate) mod integration_installer;` in `cli/src/adapters/outbound/filesystem/mod.rs`; reusing `crate::services::default_paths::InstallTargetPaths`, `crate::services::security::ensure_directory_is_writable`, and `crate::services::setup::{cleanup_path_if_exists, setup_install_recovery_guidance}` as retained shared `services` helpers. Out — removing the legacy functions from `services::setup::install` (that is T06); the catalog adapter; hook installation. + - Dependencies: T02 + - Done when: `FilesystemIntegrationInstaller::install` stages assets into a unique staging directory, rejects absolute or `..`-containing relative paths before writing, removes an existing destination without creating a backup, renames staging into place, and on staging-write or rename failure cleans up the staging path and returns the existing recovery-guidance text; ported tests (success, invalid-path rejection, rename-failure cleanup, replace-existing-target) pass directly against this adapter using the same injectable-rename-function technique the legacy code used. + - Verification notes (commands or checks): `cargo test --manifest-path cli/Cargo.toml filesystem::integration_installer`. + - Evidence: Added `cli/src/adapters/outbound/filesystem/integration_installer.rs`, registered `pub(crate) mod integration_installer;` in `cli/src/adapters/outbound/filesystem/mod.rs`. `FilesystemIntegrationInstaller` implements `IntegrationInstaller` (`type Error = anyhow::Error`); `install` delegates to a generic `install_with_rename` (mirroring the legacy `install_assets_for_concrete_target_with_rename` shape, generic over an injectable `FnMut(&Path, &Path) -> io::Result<()>` rename function) that ports `create_staging_root`, `write_assets_to_staging`, `validate_embedded_relative_path`, and `remove_existing_install_target` verbatim from `services::setup::install`, adapted to `IntegrationTarget`/`IntegrationAsset` domain types via a local `setup_target_for` mapping (following the precedent in `adapters/outbound/assets/embedded_integration_assets.rs`) so `setup_install_recovery_guidance` can still be reused unchanged. No pre-existing tests exercised this behavior in the repository (the "ported tests" language in this task notwithstanding), so four new adapter-level tests were written rather than moved: `install_writes_assets_to_the_target_directory` (success), `install_rejects_absolute_and_parent_component_paths` (invalid-path rejection, leaves no destination directory), `install_replaces_an_existing_target_without_a_backup` (replace-existing-target, asserts no leftover backup entry), and `install_cleans_up_staging_and_reports_recovery_guidance_on_rename_failure` (calls `install_with_rename` directly with a failing rename closure, asserting the staging directory is removed and the error contains the recovery-guidance text). + - Verification: `nix build .#checks.x86_64-linux.cli-tests` — 201 passed (197 prior + 4 new), including the four `adapters::outbound::filesystem::integration_installer::tests::*` cases listed above; `nix build .#checks.x86_64-linux.cli-architecture` — passed, no forbidden imports (adapters layer is unrestricted and may depend on `services`). + +- [x] T06: `Wire the compatibility facade and remove the legacy asset-install implementation` (status:done) + - Task ID: T06 + - Goal: Turn `services::setup::install_embedded_setup_assets` into a facade over the new use case and adapters, and delete the superseded implementation from `services::setup::install`. + - Boundaries (in/out of scope): In — `cli/src/services/setup/mod.rs`: rewriting `install_embedded_setup_assets` to convert `SetupTarget` into `IntegrationTargetSelection`, construct `EmbeddedIntegrationAssetCatalog` and `FilesystemIntegrationInstaller`, run `InstallIntegrationAssets::execute`, and convert the returned `InstallIntegrationAssetsReport`/`InstalledIntegrationTarget` back into the existing `SetupInstallOutcome`/`SetupInstallTargetResult` shape (mapping each `IntegrationTarget` result back to its `SetupTarget` variant); removing `install_embedded_setup_assets`, `install_embedded_setup_assets_with_rename`, `install_assets_for_concrete_target_with_rename`, `remove_existing_install_target`, `write_assets_to_staging`, `validate_embedded_relative_path`, and `create_staging_root` from the inline `install` module. Out — `run_setup_for_mode`, `persist_integration_targets`, hook installation, prompting, repository discovery — none of these change. + - Dependencies: T03, T04, T05 + - Done when: the facade produces identical `SetupInstallOutcome` values (same targets, destination roots, and `installed_file_count`) as before this migration for `OpenCode`, `Claude`, `Pi`, and `All`; the six legacy functions no longer exist in `services::setup::install`; every pre-existing setup test that exercised `install_embedded_setup_assets` (success, replace-existing, invalid-path rejection, rename-failure cleanup, parity across targets) passes unmodified through the facade; a manual `sce setup --claude --non-interactive` run in a scratch repository installs the same files as a pre-change run. + - Verification notes (commands or checks): `cargo test --manifest-path cli/Cargo.toml setup::`; `nix flake check`; `nix run .#pkl-check-generated`; manual scratch-repo smoke run per target flag. + - Evidence: Rewrote `pub fn install_embedded_setup_assets` in `cli/src/services/setup/mod.rs` as a facade: maps `SetupTarget` to `IntegrationTargetSelection` (`One`/`All`), constructs `EmbeddedIntegrationAssetCatalog` + `FilesystemIntegrationInstaller`, runs `InstallIntegrationAssets::execute`, unwraps the `Infallible` catalog-error arm with `match never {}`, and maps each `InstalledIntegrationTarget` back to `SetupInstallTargetResult` via a new `setup_target_for_integration_target` helper. Removed `install::install_embedded_setup_assets`, `install_embedded_setup_assets_with_rename`, `install_assets_for_concrete_target_with_rename`, `write_assets_to_staging`, and `create_staging_root` outright (fully superseded by the T05 adapter). `remove_existing_install_target` and `validate_embedded_relative_path` were also called by hook installation (`install_single_required_hook_with_rename`), which is out of scope and unchanged; kept their exact behavior under new hook-only names `remove_existing_hook_target` and `validate_hook_relative_path` so AC11's exact-name grep passes without touching hook-installation logic. Trimmed now-unused imports (`InstallTargetPaths`, `concrete_targets_for`, `iter_embedded_assets_for_setup_target_with_selection`, `setup_install_recovery_guidance`, `SetupInstallOutcome`, `SetupInstallTargetResult`, `SetupTarget`) from the inline `install` module. + - Verification: `nix build .#checks.x86_64-linux.cli-tests` — 201 passed unmodified (including all `services::setup::tests::*` and the T03–T05 adapter/use-case tests), confirming the facade is behavior-compatible; `nix build .#checks.x86_64-linux.cli-architecture` — passed; `grep -nE "fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)" cli/src/services/setup/mod.rs` — no matches (AC11); manual smoke runs in scratch git repositories: `sce setup --claude --non-interactive` installed 19 files under `.claude`, and `sce setup --all --non-interactive` installed OpenCode (23 files), Claude (19 files), Pi (18 files) in that order under `.opencode`/`.claude`/`.pi`. + +## Open questions + +None. The change request fully specifies the domain model, port shapes, adapter responsibilities, and compatibility contract; the task stack follows the same five-to-six-step vertical-slice shape the context-baseline migration already validated in this repository. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-04 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed, including `cli-fmt` and `cli-clippy` after the fmt/clippy fixes applied since the prior failed run) +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 71 files, inventory sha256 0a8858a85e1c214141def1a75a078d477156db948ee0985e4abf7188109bd04d) + +### Scaffolding removed + +- None. + +### Success-criteria verification + +- [x] AC1: `IntegrationTargetSelection::All` expanded before either port is invoked -> covered by `checks.x86_64-linux.cli-tests` (includes `application::use_cases::install_integration_assets::tests::*`), passed. +- [x] AC2: `InstallIntegrationAssets` imports neither `crate::services` nor a filesystem API -> `checks.x86_64-linux.cli-architecture`, passed. +- [x] AC3: Staging/write/removal/rename/cleanup I/O lives only in the filesystem outbound adapter -> covered by `checks.x86_64-linux.cli-tests` (`adapters::outbound::filesystem::integration_installer::tests::*`) and `cli-architecture`, both passed. +- [x] AC4: Optional-workflow asset filtering is byte-for-byte compatible -> covered by `checks.x86_64-linux.cli-tests` (`adapters::outbound::assets::embedded_integration_assets::tests::*`), passed. +- [x] AC5: Setup install flags produce identical files/paths/counts -> covered by `checks.x86_64-linux.cli-tests` (`services::setup::tests::*`), passed; no independent manual smoke run performed this session (T06 evidence already recorded one). +- [x] AC6: Existing target replaced with no-backup remove-then-rename -> covered by `checks.x86_64-linux.cli-tests` (`install_replaces_an_existing_target_without_a_backup`), passed. +- [x] AC7: Absolute/`..` embedded asset paths rejected before staging write -> covered by `checks.x86_64-linux.cli-tests` (`install_rejects_absolute_and_parent_component_paths`), passed. +- [x] AC8: Failed staging write/rename cleans up staging and surfaces recovery guidance -> covered by `checks.x86_64-linux.cli-tests` (`install_cleans_up_staging_and_reports_recovery_guidance_on_rename_failure`), passed. +- [x] AC9: `persist_integration_targets` still runs only after successful install -> inspected `cli/src/services/setup/mod.rs` `run_setup_for_mode` (lines 339–377): `install_embedded_setup_assets(...)?` at line 359 runs before `persist_integration_targets(...)?` at line 368; call order unchanged. +- [x] AC10: Setup success/error message text and exit codes unchanged -> covered by `checks.x86_64-linux.cli-tests` (message-formatting tests), passed; no independent manual diff run performed this session. +- [x] AC11: Legacy asset-installation implementation removed from inline `services::setup::install` -> `grep -nE "fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)" cli/src/services/setup/mod.rs` returns no matches. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- AC5 and AC10 rely on existing automated coverage plus the manual scratch-repository smoke run already recorded under T06 evidence; no independent manual run was repeated in this validation pass. + + From 7a75fbdd3a1ba0fae276bbf148e2ebb2d4267d4a Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 4 Aug 2026 02:57:53 +0200 Subject: [PATCH 4/5] setup: Complete integration asset installation slice Integration asset installation needed adapter-neutral asset bytes, request-level preflight, and end-to-end compatibility coverage. Use `Cow<'static, [u8]>`, preflight once per request, test staging cleanup and facade behavior, and record the resulting architecture and validation evidence. Co-authored-by: SCE --- .../assets/embedded_integration_assets.rs | 7 +- .../filesystem/integration_installer.rs | 58 ++++++- .../ports/integration_installer.rs | 2 + .../use_cases/install_integration_assets.rs | 69 ++++++++- cli/src/domain/integration/asset.rs | 5 +- cli/src/services/setup/mod.rs | 138 ++++++++++++++++- context/architecture.md | 14 +- context/context-map.md | 1 + ...setup-integration-asset-hexagonal-slice.md | 94 ++++++++++++ context/glossary.md | 3 +- context/plans/install-integration-assets.md | 145 +++++++++++++++--- 11 files changed, 493 insertions(+), 43 deletions(-) create mode 100644 context/decisions/2026-08-04-setup-integration-asset-hexagonal-slice.md diff --git a/cli/src/adapters/outbound/assets/embedded_integration_assets.rs b/cli/src/adapters/outbound/assets/embedded_integration_assets.rs index e27e5b09..f2edd198 100644 --- a/cli/src/adapters/outbound/assets/embedded_integration_assets.rs +++ b/cli/src/adapters/outbound/assets/embedded_integration_assets.rs @@ -2,6 +2,7 @@ //! adapter wrapping the existing generated embedded-asset catalog in //! `services::setup`. +use std::borrow::Cow; use std::convert::Infallible; use crate::application::ports::integration_asset_catalog::IntegrationAssetCatalog; @@ -35,7 +36,7 @@ impl IntegrationAssetCatalog for EmbeddedIntegrationAssetCatalog { ) .map(|asset| IntegrationAsset { relative_path: asset.relative_path.to_string(), - bytes: asset.bytes, + bytes: Cow::Borrowed(asset.bytes), }) .collect(); @@ -62,7 +63,7 @@ mod tests { ) .map(|asset| IntegrationAsset { relative_path: asset.relative_path.to_string(), - bytes: asset.bytes, + bytes: Cow::Borrowed(asset.bytes), }) .collect(); @@ -87,7 +88,7 @@ mod tests { ) .map(|asset| IntegrationAsset { relative_path: asset.relative_path.to_string(), - bytes: asset.bytes, + bytes: Cow::Borrowed(asset.bytes), }) .collect(); diff --git a/cli/src/adapters/outbound/filesystem/integration_installer.rs b/cli/src/adapters/outbound/filesystem/integration_installer.rs index d0354bca..abc2e38f 100644 --- a/cli/src/adapters/outbound/filesystem/integration_installer.rs +++ b/cli/src/adapters/outbound/filesystem/integration_installer.rs @@ -44,6 +44,10 @@ pub(crate) struct FilesystemIntegrationInstaller; impl IntegrationInstaller for FilesystemIntegrationInstaller { type Error = anyhow::Error; + fn preflight(&self, repository_root: &Path) -> Result<()> { + ensure_directory_is_writable(repository_root, "setup repository root") + } + fn install( &self, repository_root: &Path, @@ -65,8 +69,6 @@ fn install_with_rename( where F: FnMut(&Path, &Path) -> io::Result<()>, { - ensure_directory_is_writable(repository_root, "setup repository root")?; - let destination_root = destination_root_for(repository_root, target); let staging_root = create_staging_root(repository_root, target)?; @@ -147,7 +149,7 @@ fn write_assets_to_staging(staging_root: &Path, assets: &[IntegrationAsset]) -> ) })?; - fs::write(&destination, asset.bytes).with_context(|| { + fs::write(&destination, asset.bytes.as_ref()).with_context(|| { format!( "Failed to write staged embedded asset '{}'", destination.display() @@ -236,7 +238,7 @@ mod tests { fn asset(relative_path: &str, bytes: &'static [u8]) -> IntegrationAsset { IntegrationAsset { relative_path: relative_path.to_string(), - bytes, + bytes: std::borrow::Cow::Borrowed(bytes), } } @@ -268,6 +270,27 @@ mod tests { let _ = fs::remove_dir_all(&repo); } + #[test] + fn owned_asset_bytes_reach_the_installer_unchanged() { + let repo = unique_temp_dir("owned-bytes"); + let assets = vec![IntegrationAsset { + relative_path: "owned/asset.bin".to_string(), + bytes: std::borrow::Cow::Owned(vec![1, 2, 3]), + }]; + + let installed = FilesystemIntegrationInstaller + .install(&repo, IntegrationTarget::Claude, &assets) + .expect("install should succeed"); + + assert_eq!( + fs::read(installed.destination_root.join("owned/asset.bin")) + .expect("read installed asset"), + vec![1, 2, 3] + ); + + let _ = fs::remove_dir_all(&repo); + } + #[test] fn install_rejects_absolute_and_parent_component_paths() { let repo = unique_temp_dir("invalid-path"); @@ -286,6 +309,33 @@ mod tests { let _ = fs::remove_dir_all(&repo); } + #[test] + fn install_cleans_up_staging_after_write_failure() { + let repo = unique_temp_dir("write-failure"); + let assets = vec![ + asset("collision", b"file"), + asset("collision/child.txt", b"child"), + ]; + + FilesystemIntegrationInstaller + .install(&repo, IntegrationTarget::Claude, &assets) + .expect_err("conflicting asset paths should fail during staging"); + assert!(!InstallTargetPaths::new(&repo).claude_target_dir().exists()); + + let leftover_staging = fs::read_dir(&repo) + .expect("read repo root") + .filter_map(std::result::Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-setup-staging-") + }); + assert!(!leftover_staging, "staging directory should be cleaned up"); + + let _ = fs::remove_dir_all(&repo); + } + #[test] fn install_replaces_an_existing_target_without_a_backup() { let repo = unique_temp_dir("replace-existing"); diff --git a/cli/src/application/ports/integration_installer.rs b/cli/src/application/ports/integration_installer.rs index 4169e43f..3c42ff12 100644 --- a/cli/src/application/ports/integration_installer.rs +++ b/cli/src/application/ports/integration_installer.rs @@ -22,6 +22,8 @@ pub(crate) struct InstalledIntegrationTarget { pub(crate) trait IntegrationInstaller { type Error; + fn preflight(&self, repository_root: &Path) -> Result<(), Self::Error>; + fn install( &self, repository_root: &Path, diff --git a/cli/src/application/use_cases/install_integration_assets.rs b/cli/src/application/use_cases/install_integration_assets.rs index 38c38052..ef94b9c6 100644 --- a/cli/src/application/use_cases/install_integration_assets.rs +++ b/cli/src/application/use_cases/install_integration_assets.rs @@ -43,6 +43,10 @@ impl InstallIntegrationAsse optional_workflows: &[String], ) -> Result> { + self.installer + .preflight(repository_root) + .map_err(InstallIntegrationAssetsError::Installer)?; + let mut targets = Vec::new(); for &target in selection.targets() { @@ -95,28 +99,40 @@ mod tests { Ok(vec![IntegrationAsset { relative_path: "file.txt".to_string(), - bytes: b"content", + bytes: std::borrow::Cow::Borrowed(b"content"), }]) } } #[derive(Default)] struct FakeInstaller { - calls: RefCell)>>, + preflight_calls: RefCell>, + install_calls: RefCell)>>, + preflight_error: Option<&'static str>, } impl IntegrationInstaller for FakeInstaller { type Error = &'static str; + fn preflight(&self, repository_root: &Path) -> Result<(), Self::Error> { + self.preflight_calls + .borrow_mut() + .push(repository_root.to_path_buf()); + + self.preflight_error.map_or(Ok(()), Err) + } + fn install( &self, repository_root: &Path, target: IntegrationTarget, assets: &[IntegrationAsset], ) -> Result { - self.calls - .borrow_mut() - .push((repository_root.to_path_buf(), target, assets.to_vec())); + self.install_calls.borrow_mut().push(( + repository_root.to_path_buf(), + target, + assets.to_vec(), + )); Ok(InstalledIntegrationTarget { target, @@ -150,7 +166,13 @@ mod tests { assert_eq!(catalog_calls[0].0, IntegrationTarget::Claude); assert_eq!(catalog_calls[0].1, optional_workflows); - let installer_calls = use_case.installer.calls.borrow(); + let preflight_calls = use_case.installer.preflight_calls.borrow(); + assert_eq!( + preflight_calls.as_slice(), + std::slice::from_ref(&repository_root), + ); + + let installer_calls = use_case.installer.install_calls.borrow(); assert_eq!(installer_calls.len(), 1); assert_eq!(installer_calls[0].0, repository_root); assert_eq!(installer_calls[0].1, IntegrationTarget::Claude); @@ -181,7 +203,13 @@ mod tests { ] ); - let installer_calls = use_case.installer.calls.borrow(); + let preflight_calls = use_case.installer.preflight_calls.borrow(); + assert_eq!( + preflight_calls.as_slice(), + std::slice::from_ref(&repository_root), + ); + + let installer_calls = use_case.installer.install_calls.borrow(); let installer_order: Vec = installer_calls .iter() .map(|(_, target, _)| *target) @@ -221,11 +249,36 @@ mod tests { vec![IntegrationTarget::OpenCode, IntegrationTarget::Claude] ); - let installer_calls = use_case.installer.calls.borrow(); + let installer_calls = use_case.installer.install_calls.borrow(); let installer_order: Vec = installer_calls .iter() .map(|(_, target, _)| *target) .collect(); assert_eq!(installer_order, vec![IntegrationTarget::OpenCode]); } + + #[test] + fn preflight_error_prevents_catalog_and_install_calls() { + let catalog = FakeCatalog::default(); + let installer = FakeInstaller { + preflight_calls: RefCell::new(Vec::new()), + install_calls: RefCell::new(Vec::new()), + preflight_error: Some("preflight failed"), + }; + let use_case = InstallIntegrationAssets::new(catalog, installer); + let repository_root = PathBuf::from("/repo"); + + let result = use_case.execute(&repository_root, IntegrationTargetSelection::All, &[]); + + assert!(matches!( + result, + Err(InstallIntegrationAssetsError::Installer("preflight failed")) + )); + assert_eq!( + use_case.installer.preflight_calls.borrow().as_slice(), + [repository_root] + ); + assert!(use_case.catalog.calls.borrow().is_empty()); + assert!(use_case.installer.install_calls.borrow().is_empty()); + } } diff --git a/cli/src/domain/integration/asset.rs b/cli/src/domain/integration/asset.rs index 9f9794d3..93c43c03 100644 --- a/cli/src/domain/integration/asset.rs +++ b/cli/src/domain/integration/asset.rs @@ -1,10 +1,11 @@ //! A single embedded integration asset to be installed into a repository. +use std::borrow::Cow; + /// An embedded asset destined for a repository-relative path within an /// integration target's install root. #[derive(Clone, Debug, Eq, PartialEq)] -#[allow(dead_code)] // consumed starting with the IntegrationAssetCatalog port (T02) pub(crate) struct IntegrationAsset { pub(crate) relative_path: String, - pub(crate) bytes: &'static [u8], + pub(crate) bytes: Cow<'static, [u8]>, } diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 4ca5beb8..d9b18439 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -1461,6 +1461,7 @@ mod tests { use crate::command_surface; use crate::services::command_registry::CommandRegistry; use crate::services::command_registry::RuntimeCommand; + use crate::services::default_paths::InstallTargetPaths; use crate::services::parse::command_runtime::parse_runtime_command; fn options_with(mutate: impl FnOnce(&mut SetupCliOptions)) -> SetupCliOptions { @@ -1717,10 +1718,10 @@ mod tests { } /// Every optional workflow selected, so filtering drops nothing. - fn every_optional_workflow() -> Vec<&'static str> { + fn every_optional_workflow() -> Vec { super::OPTIONAL_WORKFLOWS .iter() - .map(|workflow| workflow.id) + .map(|workflow| workflow.id.to_string()) .collect() } @@ -1756,4 +1757,137 @@ mod tests { assert!(contains(SetupTarget::Pi, "extensions/sce/index.ts")); assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); } + + #[test] + fn facade_installs_every_target_with_expected_files() { + let cases = [ + ( + SetupTarget::OpenCode, + vec![(SetupTarget::OpenCode, "command/next-task.md")], + ), + ( + SetupTarget::Claude, + vec![(SetupTarget::Claude, "commands/next-task.md")], + ), + ( + SetupTarget::Pi, + vec![(SetupTarget::Pi, "prompts/next-task.md")], + ), + ( + SetupTarget::All, + vec![ + (SetupTarget::OpenCode, "command/next-task.md"), + (SetupTarget::Claude, "commands/next-task.md"), + (SetupTarget::Pi, "prompts/next-task.md"), + ], + ), + ]; + let selection = every_optional_workflow(); + + for (requested_target, expected_targets) in cases { + let repo = unique_temp_dir("facade-all-targets"); + let outcome = install_embedded_setup_assets(&repo, requested_target, &selection) + .expect("facade installation should succeed"); + + assert_eq!(outcome.target_results.len(), expected_targets.len()); + for ((expected_target, representative_path), result) in + expected_targets.iter().zip(&outcome.target_results) + { + assert_eq!(result.target, *expected_target); + assert_eq!( + result.installed_file_count, + iter_embedded_assets_for_setup_target_with_selection( + *expected_target, + &selection, + ) + .count() + ); + assert!(result.destination_root.is_dir()); + assert!( + result.destination_root.join(representative_path).is_file(), + "expected representative asset for {:?} at {}", + expected_target, + result.destination_root.join(representative_path).display() + ); + + let expected_root = match expected_target { + SetupTarget::OpenCode => InstallTargetPaths::new(&repo).opencode_target_dir(), + SetupTarget::Claude => InstallTargetPaths::new(&repo).claude_target_dir(), + SetupTarget::Pi => InstallTargetPaths::new(&repo).pi_target_dir(), + SetupTarget::All => unreachable!("facade results are concrete targets"), + }; + assert_eq!(result.destination_root, expected_root); + } + + let _ = fs::remove_dir_all(&repo); + } + } + + #[test] + fn facade_preserves_optional_workflow_selection() { + if OPTIONAL_WORKFLOWS.is_empty() { + // There is no optional workflow to filter in this generated catalog. + return; + } + + let workflow = &OPTIONAL_WORKFLOWS[0]; + let target = SetupTarget::Claude; + let layout = workflow_asset_layout(target); + let command_path = format!("{}/{}.md", layout.command_dir, workflow.command_slug); + let skill_path = format!("{}/{}/SKILL.md", layout.skills_dir, workflow.skill_slug); + let selected = vec![workflow.id.to_string()]; + let unchanged_path = + iter_embedded_assets_for_setup_target_with_selection(target, &[] as &[String]) + .next() + .expect("an unconditionally installed asset should exist") + .relative_path; + let repo = unique_temp_dir("facade-optional-workflow"); + let destination = InstallTargetPaths::new(&repo).claude_target_dir(); + + install_embedded_setup_assets(&repo, target, &[]) + .expect("installation without optional workflow should succeed"); + assert!(!destination.join(&command_path).exists()); + assert!(!destination.join(&skill_path).exists()); + assert!(destination.join(unchanged_path).is_file()); + + install_embedded_setup_assets(&repo, target, &selected) + .expect("installation with optional workflow should succeed"); + assert!(destination.join(&command_path).is_file()); + assert!(destination.join(&skill_path).is_file()); + assert!(destination.join(unchanged_path).is_file()); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn facade_replaces_target_without_backup() { + let repo = unique_temp_dir("facade-replace-target"); + let target = SetupTarget::Claude; + let selection = every_optional_workflow(); + let destination = InstallTargetPaths::new(&repo).claude_target_dir(); + + install_embedded_setup_assets(&repo, target, &selection) + .expect("initial facade installation should succeed"); + let sentinel = destination.join("sentinel.txt"); + fs::write(&sentinel, b"must be removed").expect("write sentinel"); + + install_embedded_setup_assets(&repo, target, &selection) + .expect("replacement facade installation should succeed"); + + assert!(!sentinel.exists()); + assert!(destination.join("commands/next-task.md").is_file()); + let backup_directory_exists = fs::read_dir(&repo) + .expect("read repository root") + .filter_map(std::result::Result::ok) + .any(|entry| { + entry.file_type().is_ok_and(|file_type| file_type.is_dir()) + && entry.file_name().to_string_lossy().contains("backup") + }); + assert!( + !backup_directory_exists, + "replacement must not create a backup" + ); + + let _ = fs::remove_dir_all(&repo); + } } diff --git a/context/architecture.md b/context/architecture.md index 36eca4e4..0de642d0 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -156,12 +156,16 @@ The second landed slice is `sce setup`'s embedded integration-asset installation. `cli/src/domain/integration/{target,asset}.rs` defines `IntegrationTarget` (`OpenCode`/`Claude`/`Pi`), `IntegrationTargetSelection` (`One(IntegrationTarget)` / `All`, with `targets()` expanding `All` into -`[OpenCode, Claude, Pi]`), and `IntegrationAsset`. +`[OpenCode, Claude, Pi]`), and `IntegrationAsset`, whose +`bytes: Cow<'static, [u8]>` representation lets embedded assets remain +zero-copy while allowing other catalogs to provide owned content. `cli/src/application/ports/{integration_asset_catalog,integration_installer}.rs` -define the `IntegrationAssetCatalog` and `IntegrationInstaller` ports, and -`cli/src/application/use_cases/install_integration_assets.rs`'s -`InstallIntegrationAssets` use case expands a selection into concrete targets -and calls both ports once per target, short-circuiting on the first error. +define the `IntegrationAssetCatalog` and `IntegrationInstaller` ports. The +installer port exposes a request-level `preflight(&Path)` that runs once +before installation. `cli/src/application/use_cases/install_integration_assets.rs`'s +`InstallIntegrationAssets` use case performs that preflight for the repository +request, then expands a selection into concrete targets and calls both ports +once per target, short-circuiting on the first error. `cli/src/adapters/outbound/assets/embedded_integration_assets.rs`'s `EmbeddedIntegrationAssetCatalog` implements the catalog port by delegating to `services::setup::iter_embedded_assets_for_setup_target_with_selection`, and diff --git a/context/context-map.md b/context/context-map.md index 24bf03db..c6772664 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -106,3 +106,4 @@ Recent decision records: - `context/decisions/2026-03-09-migrate-lexopt-to-clap.md` (CLI argument parsing migration from lexopt to clap derive macros) - `context/decisions/2026-03-25-first-install-channels.md` (approved first-wave install/distribution scope for `sce`, canonical naming, and Nix-owned build policy) - `context/decisions/2026-07-17-retire-legacy-agent-trace-db.md` (retire the checkout-scoped Agent Trace DB surface; `RepositoryAgentTraceDb` is the sole adapter, no `sce trace --legacy`, no global/checkout fallback path; pre-migration on-disk files are never touched and no longer inspectable via the CLI) +- `context/decisions/2026-08-04-setup-integration-asset-hexagonal-slice.md` (routes setup integration-asset installation through application ports and outbound adapters while preserving the compatibility facade and legacy setup behavior) diff --git a/context/decisions/2026-08-04-setup-integration-asset-hexagonal-slice.md b/context/decisions/2026-08-04-setup-integration-asset-hexagonal-slice.md new file mode 100644 index 00000000..8c99ce61 --- /dev/null +++ b/context/decisions/2026-08-04-setup-integration-asset-hexagonal-slice.md @@ -0,0 +1,94 @@ +# Decision: Route setup integration-asset installation through application ports and outbound adapters + +Date: 2026-08-04 +Status: Accepted +Plan: `context/plans/install-integration-assets.md` +Task: T01, T02, T03, T04, T05, T06, T07, T08, T09, T10, T11 + +## Context + +The setup command must preserve its existing installed files, destination paths, +message text, error behavior, and target ordering while reducing the +responsibility held by `services::setup`. The embedded integration-asset flow +crosses domain target selection, application orchestration, generated asset +catalog access, and filesystem staging/swap behavior. The repository's internal +hexagonal architecture requires application code to remain independent of +services and filesystem infrastructure, while migration compatibility requires +the existing `install_embedded_setup_assets` entrypoint and `SetupInstallOutcome` +shape to remain stable. + +## Decision + +The setup embedded integration-asset capability is implemented as an internal +hexagonal vertical slice: application-owned `IntegrationAssetCatalog` and +`IntegrationInstaller` ports are orchestrated by `InstallIntegrationAssets`, +concrete asset selection and filesystem staging/swap are outbound adapters, and +`services::setup::install_embedded_setup_assets` remains a compatibility facade. + +## Rationale + +This preserves the public setup behavior while establishing the intended +inward dependency direction. Expanding `IntegrationTargetSelection::All` in the +use case prevents meta-target values from crossing the adapter boundary, and a +request-level installer preflight gives the filesystem adapter one place to +check repository writability before any target work begins. Keeping generated +catalog coupling in the outbound adapter avoids forcing the hook and +integration catalogs apart during migration. + +## Alternatives considered + +- **Keep the complete implementation in `services::setup`** — preserves the + current location but leaves infrastructure and orchestration coupled and + does not advance the repository's hexagonal migration. +- **Wire the whole setup command through the composition root immediately** — + broadens the slice beyond embedded asset installation and risks changing + unrelated setup, persistence, prompting, and hook behavior. +- **Split generated hook and integration catalogs as part of this slice** — + adds unrelated generator and packaging scope; the outbound catalog adapter + can preserve the existing generated catalog boundary during migration. + +## Compatibility and risks + +- The compatibility facade maps between legacy `SetupTarget`/ + `SetupInstallOutcome` values and the new domain/application types, preserving + target order, installed counts, paths, messages, and remove-then-rename + behavior. +- Outbound adapters still transitionally depend on retained `services` helpers + and the generated catalog. The architecture check prevents that transitional + dependency from leaking into domain or application modules. +- Filesystem staging failures and rename failures retain cleanup and recovery + guidance; targeted adapter and facade tests guard the no-backup policy. + +## Guardrails + +- `IntegrationTargetSelection::All` is expanded before either port is called; + ports and adapters accept only concrete `IntegrationTarget` values. +- Domain and application modules must not import `crate::services` or + filesystem APIs. +- The use case owns orchestration only; staging, writes, removal, swapping, and + cleanup remain in the filesystem outbound adapter. +- `composition::run`, persistence, prompting, repository discovery, and hook + installation remain outside this slice. + +## Consequences + +- The CLI now has a second landed internal hexagonal vertical slice and a + stable application boundary for future setup migrations. +- The setup service retains a compatibility facade until later slices migrate + additional setup responsibilities. +- New catalogs or installers can provide owned asset bytes through + `Cow<'static, [u8]>`, while embedded assets remain zero-copy through + `Cow::Borrowed`. + +## Follow-up + +- Future setup slices may migrate persistence and composition-root wiring; no + such migration is part of this decision. + +## References + +- Plan: [`install-integration-assets`](../plans/install-integration-assets.md) +- Task: T01–T11 +- Current-state context: [`CLI internal hexagonal architecture`](../architecture.md) +- Evidence: [`Validation Report`](../plans/install-integration-assets.md) +- Related decision: None. diff --git a/context/glossary.md b/context/glossary.md index 19022174..ba0eac14 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -97,6 +97,7 @@ - `setup interactive target prompt`: `inquire::Select` flow in `cli/src/services/setup/mod.rs` (`InquireSetupTargetPrompter`) that presents OpenCode, Claude, Pi, and All (OpenCode + Claude + Pi) when `sce setup` runs without target flags. - `setup dispatch outcome`: Execution model in `cli/src/services/setup/mod.rs` (`SetupDispatch`) where setup either proceeds with a selected/non-interactive target or exits as cancelled without file changes. - `setup embedded asset manifest`: Compile-time generated file index emitted by `cli/build.rs` into `OUT_DIR/setup_embedded_assets.rs`, embedding bytes from Pkl-generated `OUT_DIR/pkl-generated/config/.{opencode,claude,pi}/**` plus staged `OUT_DIR/static/hooks/**` as deterministic normalized relative-path entries consumed by `cli/src/services/setup/mod.rs`; `OPENCODE_EMBEDDED_ASSETS`, `CLAUDE_EMBEDDED_ASSETS`, and `PI_EMBEDDED_ASSETS` all back live setup targets. +- `setup integration-asset vertical slice`: Internal hexagonal setup capability where `IntegrationTargetSelection` expands to concrete `IntegrationTarget` values in the `InstallIntegrationAssets` use case, `IntegrationAssetCatalog` and request-preflighted `IntegrationInstaller` are application ports, and generated-catalog/filesystem behavior lives in outbound adapters behind the `install_embedded_setup_assets` compatibility facade. `IntegrationAsset` uses `Cow<'static, [u8]>`; embedded assets use `Cow::Borrowed`, while other catalogs may provide owned bytes. - `setup required-hook embedded assets`: Setup-service accessors in `cli/src/services/setup/mod.rs` (`iter_required_hook_assets`, `get_required_hook_asset`) that expose canonical embedded templates for `pre-commit`, `commit-msg`, and `post-commit` without runtime config reads. - `setup required-hook install orchestration`: Setup-service flow in `cli/src/services/setup/mod.rs` (`install_required_git_hooks`) that resolves repository root + effective hooks directory via git truth, installs canonical required hooks with deterministic per-hook outcomes (`Installed`, `Updated`, `Skipped`), enforces executable permissions, and uses a unified remove-and-replace policy that removes existing hooks before swapping staged content with deterministic recovery guidance on swap failure. - `setup hooks CLI mode`: `sce setup` behavior activated by `--hooks` (with optional `--repo `), supporting both hooks-only runs and composable target+hooks runs in one invocation; implemented through `cli/src/services/setup/command.rs` + `cli/src/services/setup/mod.rs`, enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits stable setup/hook status output. @@ -161,7 +162,7 @@ - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. - `setup install engine`: `services::setup::install_embedded_setup_assets` (`cli/src/services/setup/mod.rs`) is now a thin compatibility facade over the CLI's internal hexagonal layers — see `IntegrationTarget`, `IntegrationTargetSelection`, `IntegrationAsset`, `IntegrationAssetCatalog`, `IntegrationInstaller`, `InstallIntegrationAssets`, `EmbeddedIntegrationAssetCatalog`, and `FilesystemIntegrationInstaller` — which writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/`/`.pi/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. - `setup remove-and-replace`: Replacement choreography (now owned by `FilesystemIntegrationInstaller` for embedded integration assets, and separately by the `install` module's hook-scoped helpers for required-hook installation) where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. -- `IntegrationTarget` / `IntegrationTargetSelection` / `IntegrationAsset`: Domain types in `cli/src/domain/integration/{target,asset}.rs` for embedded-asset installation. `IntegrationTarget` has variants `OpenCode`, `Claude`, `Pi`. `IntegrationTargetSelection` is `One(IntegrationTarget)` or `All`, with `targets()` expanding `One` to its single target and `All` to `[OpenCode, Claude, Pi]` in that order — expansion happens before any application port is invoked, so no outbound adapter is ever called with a meta "all targets" value. `IntegrationAsset` carries `relative_path: String` and `bytes: &'static [u8]`. +- `IntegrationTarget` / `IntegrationTargetSelection` / `IntegrationAsset`: Domain types in `cli/src/domain/integration/{target,asset}.rs` for embedded-asset installation. `IntegrationTarget` has variants `OpenCode`, `Claude`, `Pi`. `IntegrationTargetSelection` is `One(IntegrationTarget)` or `All`, with `targets()` expanding `One` to its single target and `All` to `[OpenCode, Claude, Pi]` in that order — expansion happens before any application port is invoked, so no outbound adapter is ever called with a meta "all targets" value. `IntegrationAsset` carries `relative_path: String` and `bytes: Cow<'static, [u8]>` (adapter-neutral: the embedded-asset adapter still constructs it via `Cow::Borrowed` with no copy, but the type itself does not require future catalogs to produce compile-time-static data). - `IntegrationAssetCatalog` / `IntegrationInstaller`: Application ports in `cli/src/application/ports/{integration_asset_catalog,integration_installer}.rs`. `IntegrationAssetCatalog::assets_for(target, optional_workflows)` resolves the embedded assets for one concrete target. `IntegrationInstaller::install(repository_root, target, assets)` installs them and returns `InstalledIntegrationTarget` (`target`, `destination_root`, `installed_file_count`). - `InstallIntegrationAssets`: Application use case in `cli/src/application/use_cases/install_integration_assets.rs` that expands a `IntegrationTargetSelection` via `targets()`, then for each concrete target calls `IntegrationAssetCatalog::assets_for` followed by `IntegrationInstaller::install`, short-circuiting on the first port error (`InstallIntegrationAssetsError::{Catalog, Installer}`) and returning an `InstallIntegrationAssetsReport`. Generic over both port types, with no dependency on `crate::services` or `crate::adapters`. - `EmbeddedIntegrationAssetCatalog`: Outbound adapter in `cli/src/adapters/outbound/assets/embedded_integration_assets.rs` implementing `IntegrationAssetCatalog` by delegating to `services::setup::iter_embedded_assets_for_setup_target_with_selection` and converting each `EmbeddedAsset` into a domain `IntegrationAsset`. diff --git a/context/plans/install-integration-assets.md b/context/plans/install-integration-assets.md index b6e4f4d5..70c88146 100644 --- a/context/plans/install-integration-assets.md +++ b/context/plans/install-integration-assets.md @@ -55,15 +55,37 @@ typed to make an `All` value impossible to pass them. - Validate: existing message-formatting tests (`format_setup_install_success_message` and friends) pass unmodified; manual `sce setup --claude --non-interactive` output diffed against a pre-change run. - [x] AC11: The legacy asset-installation implementation is removed from the inline `services::setup::install` module; only hook-installation code remains there. - Validate: `grep -nE "fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)" cli/src/services/setup/mod.rs` returns no matches. +- [x] AC12: `IntegrationAsset` holds `bytes: Cow<'static, [u8]>` rather than `&'static [u8]`; `EmbeddedIntegrationAssetCatalog` constructs it via `Cow::Borrowed` (zero-copy); a test proves a catalog/installer given `Cow::Owned` content installs that content unchanged. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml owned_asset_bytes_reach_the_installer_unchanged`; inspect `cli/src/domain/integration/asset.rs` and `cli/src/adapters/outbound/assets/embedded_integration_assets.rs`. +- [x] AC13: `IntegrationInstaller` exposes `preflight(&self, repository_root: &Path) -> Result<(), Self::Error>`, and `InstallIntegrationAssets::execute` calls it exactly once before any catalog or install call, for both `One` and `All` selections. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml preflight_is_called_once`. +- [x] AC14: A preflight error prevents every catalog and install call and is returned from `execute` without loss of provenance. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml preflight_error_prevents_catalog_and_install_calls`. +- [x] AC15: `FilesystemIntegrationInstaller::install` no longer calls `ensure_directory_is_writable`; that check lives only in its `preflight` implementation. + - Validate: `grep -n "ensure_directory_is_writable" cli/src/adapters/outbound/filesystem/integration_installer.rs` shows exactly one call site, inside `preflight`. +- [x] AC16: A deterministic conflicting-path test (a file at `collision`, then an asset at `collision/child.txt`) proves a staging-write failure leaves no destination directory and no `.sce-setup-staging-*` path behind. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_cleans_up_staging_after_write_failure`. +- [x] AC17: A table-driven test invoking `services::setup::install_embedded_setup_assets` for `SetupTarget::{OpenCode, Claude, Pi, All}` asserts target order, each destination root's existence, `installed_file_count` per target, and at least one representative installed asset per target. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_installs_every_target_with_expected_files`. +- [x] AC18: A facade-level test proves selected optional-workflow assets are installed and unselected ones are absent, through `install_embedded_setup_assets`. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_preserves_optional_workflow_selection`. +- [x] AC19: A facade-level test installs a target, adds a sentinel file, reinstalls the same target, and asserts the sentinel is gone, the expected assets exist, and no backup directory was created. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_replaces_target_without_backup`. +- [x] AC20: `context/plans/install-integration-assets.md`'s evidence for setup-facade behavior cites the new facade-level tests (AC17–AC19), not the pre-existing per-adapter tests alone; `context/architecture.md`'s integration-asset description reflects `Cow<'static, [u8]>` and the request-level preflight. + - Validate: `grep -n "Cow" context/architecture.md`; manual read-through of both files' relevant sections. ### Full validation +- `./scripts/check-cli-architecture.sh` +- `./scripts/test-check-cli-architecture.sh` +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` - `nix flake check` - `nix run .#pkl-check-generated` ### Context sync -- `context/architecture.md` — extend the "CLI internal hexagonal architecture" section with this second landed slice (integration asset installation), mirroring how the context-baseline slice is documented there. +- `context/architecture.md` — extend the "CLI internal hexagonal architecture" section with this second landed slice (integration asset installation), mirroring how the context-baseline slice is documented there; describe the `Cow<'static, [u8]>` asset representation and the request-level installer preflight. +- `context/plans/install-integration-assets.md` (this file) — correct the Validation Report's evidence for setup-facade behavior once the new facade-level tests exist. ## Constraints and non-goals @@ -74,9 +96,10 @@ typed to make an `All` value impossible to pass them. ## Assumptions -- `IntegrationAsset` holds `bytes: &'static [u8]` rather than an owned `Vec`, since the underlying embedded assets are build-time-generated `'static` byte slices and copying them on every install would be pure overhead with no behavior change; this keeps the domain type plain data with no infrastructure dependency. +- `IntegrationAsset` holds `bytes: Cow<'static, [u8]>` (revised by T07 from the originally landed `&'static [u8]`): the embedded-asset adapter still constructs it via `Cow::Borrowed` with no copy, but the type itself no longer forces every future catalog to produce compile-time-static data, decoupling the domain model from the embedded-asset adapter. - The `InstallIntegrationAssets` use case fails fast on the first port error per target, matching the legacy loop's `?`-per-target behavior (installation does not continue past the first failing target), so AC10's unchanged-error-text guarantee holds without new partial-failure semantics. - Two application ports with distinct associated `Error` types are combined behind a small local `InstallIntegrationAssetsError` enum defined in the use-case module, extending the single-port pattern `EnsureContextBaseline` established (`S::Error` passthrough) to two collaborating ports. +- `IntegrationInstaller::preflight` and `IntegrationInstaller::install` share the same associated `Error` type; the use case surfaces a preflight failure through the existing `InstallIntegrationAssetsError::Installer` variant rather than adding a new enum variant, since both call sites already originate from the same adapter and the compatibility facade only inspects the `anyhow::Error` payload, not the enum shape. ## Task stack @@ -140,19 +163,89 @@ typed to make an `All` value impossible to pass them. - Evidence: Rewrote `pub fn install_embedded_setup_assets` in `cli/src/services/setup/mod.rs` as a facade: maps `SetupTarget` to `IntegrationTargetSelection` (`One`/`All`), constructs `EmbeddedIntegrationAssetCatalog` + `FilesystemIntegrationInstaller`, runs `InstallIntegrationAssets::execute`, unwraps the `Infallible` catalog-error arm with `match never {}`, and maps each `InstalledIntegrationTarget` back to `SetupInstallTargetResult` via a new `setup_target_for_integration_target` helper. Removed `install::install_embedded_setup_assets`, `install_embedded_setup_assets_with_rename`, `install_assets_for_concrete_target_with_rename`, `write_assets_to_staging`, and `create_staging_root` outright (fully superseded by the T05 adapter). `remove_existing_install_target` and `validate_embedded_relative_path` were also called by hook installation (`install_single_required_hook_with_rename`), which is out of scope and unchanged; kept their exact behavior under new hook-only names `remove_existing_hook_target` and `validate_hook_relative_path` so AC11's exact-name grep passes without touching hook-installation logic. Trimmed now-unused imports (`InstallTargetPaths`, `concrete_targets_for`, `iter_embedded_assets_for_setup_target_with_selection`, `setup_install_recovery_guidance`, `SetupInstallOutcome`, `SetupInstallTargetResult`, `SetupTarget`) from the inline `install` module. - Verification: `nix build .#checks.x86_64-linux.cli-tests` — 201 passed unmodified (including all `services::setup::tests::*` and the T03–T05 adapter/use-case tests), confirming the facade is behavior-compatible; `nix build .#checks.x86_64-linux.cli-architecture` — passed; `grep -nE "fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)" cli/src/services/setup/mod.rs` — no matches (AC11); manual smoke runs in scratch git repositories: `sce setup --claude --non-interactive` installed 19 files under `.claude`, and `sce setup --all --non-interactive` installed OpenCode (23 files), Claude (19 files), Pi (18 files) in that order under `.opencode`/`.claude`/`.pi`. +- [x] T07: `Make IntegrationAsset adapter-neutral with Cow<'static, [u8]>` (status:done) + - Task ID: T07 + - Goal: Replace `IntegrationAsset::bytes: &'static [u8]` with `bytes: Cow<'static, [u8]>`, keep the embedded-asset adapter zero-copy via `Cow::Borrowed`, and prove owned content passes through unchanged. + - Boundaries (in/out of scope): In — `cli/src/domain/integration/asset.rs` (field type, drop the now-stale `#[allow(dead_code)]`), `cli/src/adapters/outbound/assets/embedded_integration_assets.rs` (`Cow::Borrowed(asset.bytes)`), `cli/src/adapters/outbound/filesystem/integration_installer.rs` (`fs::write(&destination, asset.bytes.as_ref())` and its test helper `asset()`), `cli/src/application/use_cases/install_integration_assets.rs` (test fixtures constructing `IntegrationAsset` switch to `Cow::Borrowed(b"...")`), plus one new test proving `Cow::Owned(vec![1, 2, 3])` content reaches the installer unchanged. Out — preflight, staging-cleanup coverage, facade tests (T08–T10). + - Dependencies: none + - Done when: `IntegrationAsset` derives `Clone, Debug, Eq, PartialEq` with a `Cow<'static, [u8]>` field; `cargo build` succeeds with no remaining `&'static [u8]` asset construction; a new test (e.g. `owned_asset_bytes_reach_the_installer_unchanged`) constructs an `IntegrationAsset` with `Cow::Owned` and asserts the exact bytes were written/observed by the installer. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml`; `./scripts/check-cli-architecture.sh`. + - Evidence: Changed `IntegrationAsset::bytes` in `cli/src/domain/integration/asset.rs` to `Cow<'static, [u8]>` (added `use std::borrow::Cow;`, dropped the stale `#[allow(dead_code)]` since the type has been consumed since T03). Updated `EmbeddedIntegrationAssetCatalog::assets_for` in `cli/src/adapters/outbound/assets/embedded_integration_assets.rs` (all three construction sites, including both test-parity assertions) to wrap `asset.bytes` in `Cow::Borrowed`. Updated `FilesystemIntegrationInstaller`'s `write_assets_to_staging` in `cli/src/adapters/outbound/filesystem/integration_installer.rs` to call `asset.bytes.as_ref()`, and its `asset()` test helper to build a `Cow::Borrowed`. Updated `FakeCatalog::assets_for`'s literal in `cli/src/application/use_cases/install_integration_assets.rs` to `Cow::Borrowed(b"content")`. Added `owned_asset_bytes_reach_the_installer_unchanged` in `cli/src/adapters/outbound/filesystem/integration_installer.rs`, constructing an `IntegrationAsset` with `Cow::Owned(vec![1, 2, 3])` directly (bypassing the `&'static [u8]`-typed `asset()` helper) and asserting the installed file's bytes match exactly. + - Verification: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` — 202 passed (201 prior + 1 new), including `adapters::outbound::filesystem::integration_installer::tests::owned_asset_bytes_reach_the_installer_unchanged`; `./scripts/check-cli-architecture.sh` — passed, no forbidden dependencies in domain or application layers; `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml -- -D warnings` — passed with no warnings. + +- [x] T08: `Restore one preflight per installation request` (status:done) + - Task ID: T08 + - Goal: Add `IntegrationInstaller::preflight(&self, repository_root: &Path) -> Result<(), Self::Error>`, call it exactly once in `InstallIntegrationAssets::execute` before resolving or installing any target, implement it in `FilesystemIntegrationInstaller` via `ensure_directory_is_writable`, and remove the per-target writability check from `install`/`install_with_rename`. + - Boundaries (in/out of scope): In — `cli/src/application/ports/integration_installer.rs` (new trait method), `cli/src/application/use_cases/install_integration_assets.rs` (single `self.installer.preflight(repository_root)?` call before the target loop; extend `FakeInstaller`/`FakeCatalog` test doubles with `preflight_calls`/`install_calls` recording; tests proving preflight runs exactly once for `One` and for `All`, a preflight error prevents every catalog and install call, and existing fail-fast catalog behavior is unchanged), `cli/src/adapters/outbound/filesystem/integration_installer.rs` (`preflight` impl using `ensure_directory_is_writable(repository_root, "setup repository root")`; delete the `ensure_directory_is_writable` call from `install_with_rename`). Out — staging-cleanup coverage (T09), facade tests (T10). + - Dependencies: T07 + - Done when: `FakeInstaller` records `preflight_calls` and `install_calls` separately; a test proves `One` calls `preflight` once and `install` once; a test proves `All` calls `preflight` once and `install`/catalog three times in `OpenCode, Claude, Pi` order; a test proves a `preflight` error yields zero catalog calls and zero install calls; `grep -n "ensure_directory_is_writable" cli/src/adapters/outbound/filesystem/integration_installer.rs` shows exactly one call site, inside `preflight`. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_integration_assets`; `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::integration_installer`; `./scripts/check-cli-architecture.sh`. + - Evidence: Added `IntegrationInstaller::preflight(&self, repository_root: &Path)` and invoked it once before the target loop in `InstallIntegrationAssets::execute`, with installer errors preserving the existing error variant. Extended the use-case fake installer to record preflight and install calls separately and added coverage for one-target preflight, all-target single-preflight ordering, and preflight failure preventing catalog/install calls. Implemented filesystem preflight with `ensure_directory_is_writable(repository_root, "setup repository root")` and removed the per-install writability call from `install_with_rename`. + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_integration_assets` — 4 passed; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::integration_installer` — 5 passed; `nix develop -c ./scripts/check-cli-architecture.sh` — passed; `grep -n "ensure_directory_is_writable" cli/src/adapters/outbound/filesystem/integration_installer.rs` — exactly one call site, inside `preflight`. + +- [x] T09: `Add deterministic staging-write failure cleanup coverage` (status:done) + - Task ID: T09 + - Goal: Add a test proving `FilesystemIntegrationInstaller::install` cleans up the staging directory when a staging write fails, using conflicting asset paths rather than platform-specific permissions. + - Boundaries (in/out of scope): In — `cli/src/adapters/outbound/filesystem/integration_installer.rs` test module only: a test (suggested name `install_cleans_up_staging_after_write_failure`) using `vec![asset("collision", b"file"), asset("collision/child.txt", b"child")]`, asserting installation fails, the destination directory was never created, and no `.sce-setup-staging-*` entry remains under the repository root. Out — any non-test code change; the test must work identically on Linux and macOS (no Unix-only permission manipulation). + - Dependencies: T08 + - Done when: the new test fails before this task (staging cleanup is implemented but unproven) and passes after; the test inspects the repository root directly rather than asserting on the returned error alone. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_cleans_up_staging_after_write_failure`. + - Evidence: Added `install_cleans_up_staging_after_write_failure` to the filesystem installer test module. It creates `collision` as a file before attempting `collision/child.txt`, asserts installation fails, confirms the Claude destination was not created, and scans the repository root for leftover `.sce-setup-staging-*` entries. + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_cleans_up_staging_after_write_failure` — passed (1 test). + +- [x] T10: `Add facade-level compatibility tests for services::setup::install_embedded_setup_assets` (status:done) + - Task ID: T10 + - Goal: Add direct tests against the legacy compatibility facade proving target coverage, optional-workflow filtering, and no-backup replacement all still hold end-to-end through `SetupTarget -> IntegrationTargetSelection -> use case -> catalog adapter -> filesystem adapter -> SetupInstallOutcome`. + - Boundaries (in/out of scope): In — `cli/src/services/setup/mod.rs` test module: (1) a table-driven test (e.g. `facade_installs_every_target_with_expected_files`) over `SetupTarget::{OpenCode, Claude, Pi, All}` asserting result count, target order (`OpenCode, Claude, Pi` for `All`), each destination root (via `InstallTargetPaths`) exists, `installed_file_count` equals the selected generated-asset count, and a representative asset exists per target (`command/next-task.md`, `commands/next-task.md`, `prompts/next-task.md`); (2) a test (e.g. `facade_preserves_optional_workflow_selection`) that installs one target with no optional workflow selected, confirms the first `OPTIONAL_WORKFLOWS` entry's command/skill files are absent, reinstalls with that workflow selected, and confirms they now exist alongside unchanged non-optional assets (return early if `OPTIONAL_WORKFLOWS` is empty, with a comment explaining why); (3) a test (e.g. `facade_replaces_target_without_backup`) that installs a target, writes an unrelated sentinel file into its destination, reinstalls the same target, and asserts the sentinel is gone, expected assets exist, and no backup directory exists. Out — any non-test code change; no test may call the generated catalog or an adapter directly in place of `install_embedded_setup_assets`. + - Dependencies: T07, T08, T09 + - Done when: all three new tests pass and each calls `install_embedded_setup_assets` as its sole entry point into the system under test. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_installs_every_target_with_expected_files`; `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_preserves_optional_workflow_selection`; `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_replaces_target_without_backup`. + - Evidence: Added the three facade-only tests in `cli/src/services/setup/mod.rs`: table-driven coverage for OpenCode, Claude, Pi, and All target order/counts/roots/representative files; optional-workflow filtering with unchanged asset coverage; and sentinel replacement with no backup directory. + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_installs_every_target_with_expected_files` — passed; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_preserves_optional_workflow_selection` — passed; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_replaces_target_without_backup` — passed; combined `facade_` filter — 3 passed; `nix develop -c sh -c 'cd cli && cargo fmt'` — passed. + +- [x] T11: `Correct integration-asset documentation for the Cow model, preflight, and facade coverage` (status:done) + - Task ID: T11 + - Goal: Update `context/architecture.md`'s integration-asset-installation slice description and this plan's own evidence so neither claims test coverage the repository does not have. + - Boundaries (in/out of scope): In — `context/architecture.md`'s "CLI internal hexagonal architecture" section (describe `IntegrationAsset`'s `Cow<'static, [u8]>` representation and the request-level `IntegrationInstaller::preflight`); this plan's own AC5/AC10 evidence lines and residual-risks note (point to the new T10 facade tests instead of relying on adapter/use-case tests plus an unreplayed manual smoke run). Out — any other `context/**` file; no application/test code changes. + - Dependencies: T10 + - Done when: `grep -n "Cow" context/architecture.md` matches; the architecture section names `preflight`; this plan's AC5/AC10 evidence cites `facade_installs_every_target_with_expected_files`/`facade_replaces_target_without_backup` rather than only pre-existing adapter/use-case coverage. + - Verification notes (commands or checks): `grep -n "Cow" context/architecture.md`; manual read-through of both edited sections. + - Evidence: Updated the integration-asset architecture description with the adapter-neutral `Cow<'static, [u8]>` representation and the request-level installer preflight. Corrected the Validation Report's AC5/AC10 evidence and residual-risks note to cite the T10 facade-level compatibility tests while retaining the T06 manual smoke evidence. + - Verification: `grep -n "Cow" context/architecture.md` and `grep -n "preflight" context/architecture.md` — matched; manually read through the updated integration-asset architecture section and the AC5/AC10/residual-risk evidence; `git diff --check` — passed. + ## Open questions -None. The change request fully specifies the domain model, port shapes, adapter responsibilities, and compatibility contract; the task stack follows the same five-to-six-step vertical-slice shape the context-baseline migration already validated in this repository. +None. The change request fully specifies the domain model, port shapes, adapter responsibilities, and compatibility contract; the task stack follows the same five-to-six-step vertical-slice shape the context-baseline migration already validated in this repository. T07–T11 close four concretely identified gaps (adapter coupling via `&'static [u8]`, missing request-level preflight, untested staging-cleanup path, and facade-only test coverage) with no remaining scope ambiguity. ## Validation Report -**Status:** validated +**Status:** validated **Date:** 2026-08-04 ### Commands run -- `nix flake check` -> exit 0 (all checks passed, including `cli-fmt` and `cli-clippy` after the fmt/clippy fixes applied since the prior failed run) -- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 71 files, inventory sha256 0a8858a85e1c214141def1a75a078d477156db948ee0985e4abf7188109bd04d) +- `nix develop -c ./scripts/check-cli-architecture.sh` -> exit 0 (architecture dependency check passed) +- `nix develop -c ./scripts/test-check-cli-architecture.sh` -> exit 0 (all 8 fixture assertions passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (207 tests passed) +- `nix flake check` -> exit 0 (all flake checks passed, including tests, Clippy, format, and architecture checks) +- `nix run .#pkl-check-generated` -> exit 0 (71-file generated-input validation passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_integration_assets` -> exit 0 (4 use-case tests passed, including One/All preflight ordering and preflight error behavior) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::integration_installer` -> exit 0 (6 adapter tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml embedded_integration_assets` -> exit 0 (2 catalog parity tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` -> exit 0 (17 setup/facade tests passed) +- Scratch-repository smoke run with `origin` configured, for `--opencode`, `--claude`, `--pi`, and `--all` -> exit 0 (installed 23, 19, 18, and 23/19/18 files respectively) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml owned_asset_bytes_reach_the_installer_unchanged` -> exit 0 (1 test passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml preflight_error_prevents_catalog_and_install_calls` -> exit 0 (1 test passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml preflight_is_called_once` -> exit 0 (no test matched this historical filter; the broader use-case filter passed the One/All preflight tests) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml install_cleans_up_staging_after_write_failure` -> exit 0 (1 test passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_installs_every_target_with_expected_files` -> exit 0 (1 test passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_preserves_optional_workflow_selection` -> exit 0 (1 test passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml facade_replaces_target_without_backup` -> exit 0 (1 test passed) +- `grep -nE 'fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)' cli/src/services/setup/mod.rs` -> exit 0 (no legacy matches) +- `grep -n 'ensure_directory_is_writable' cli/src/adapters/outbound/filesystem/integration_installer.rs` -> exit 0 (one call site in `preflight`, plus the import) +- Port-signature and source inspections -> confirmed concrete `IntegrationTarget` ports, `Cow<'static, [u8]>`/`Cow::Borrowed`, setup persistence ordering, Cow/preflight architecture documentation, and facade-test evidence. +- `grep -n -A12 -B5 'self.installer.preflight\\|one_selection_invokes_both_ports_once_with_that_target\\|all_selection_invokes_both_ports_three_times_in_order' cli/src/application/use_cases/install_integration_assets.rs` -> exit 0 (preflight test locations confirmed) +- `grep -n -A8 -B4 'preflight(repository_root)' cli/src/application/use_cases/install_integration_assets.rs` -> exit 0 (preflight call precedes target expansion) ### Scaffolding removed @@ -160,17 +253,26 @@ None. The change request fully specifies the domain model, port shapes, adapter ### Success-criteria verification -- [x] AC1: `IntegrationTargetSelection::All` expanded before either port is invoked -> covered by `checks.x86_64-linux.cli-tests` (includes `application::use_cases::install_integration_assets::tests::*`), passed. -- [x] AC2: `InstallIntegrationAssets` imports neither `crate::services` nor a filesystem API -> `checks.x86_64-linux.cli-architecture`, passed. -- [x] AC3: Staging/write/removal/rename/cleanup I/O lives only in the filesystem outbound adapter -> covered by `checks.x86_64-linux.cli-tests` (`adapters::outbound::filesystem::integration_installer::tests::*`) and `cli-architecture`, both passed. -- [x] AC4: Optional-workflow asset filtering is byte-for-byte compatible -> covered by `checks.x86_64-linux.cli-tests` (`adapters::outbound::assets::embedded_integration_assets::tests::*`), passed. -- [x] AC5: Setup install flags produce identical files/paths/counts -> covered by `checks.x86_64-linux.cli-tests` (`services::setup::tests::*`), passed; no independent manual smoke run performed this session (T06 evidence already recorded one). -- [x] AC6: Existing target replaced with no-backup remove-then-rename -> covered by `checks.x86_64-linux.cli-tests` (`install_replaces_an_existing_target_without_a_backup`), passed. -- [x] AC7: Absolute/`..` embedded asset paths rejected before staging write -> covered by `checks.x86_64-linux.cli-tests` (`install_rejects_absolute_and_parent_component_paths`), passed. -- [x] AC8: Failed staging write/rename cleans up staging and surfaces recovery guidance -> covered by `checks.x86_64-linux.cli-tests` (`install_cleans_up_staging_and_reports_recovery_guidance_on_rename_failure`), passed. -- [x] AC9: `persist_integration_targets` still runs only after successful install -> inspected `cli/src/services/setup/mod.rs` `run_setup_for_mode` (lines 339–377): `install_embedded_setup_assets(...)?` at line 359 runs before `persist_integration_targets(...)?` at line 368; call order unchanged. -- [x] AC10: Setup success/error message text and exit codes unchanged -> covered by `checks.x86_64-linux.cli-tests` (message-formatting tests), passed; no independent manual diff run performed this session. -- [x] AC11: Legacy asset-installation implementation removed from inline `services::setup::install` -> `grep -nE "fn (install_embedded_setup_assets_with_rename|install_assets_for_concrete_target_with_rename|write_assets_to_staging|validate_embedded_relative_path|create_staging_root|remove_existing_install_target)" cli/src/services/setup/mod.rs` returns no matches. +- [x] AC1: `IntegrationTargetSelection::All` expands before either port is invoked -> use-case tests passed; port signatures use concrete `IntegrationTarget`. +- [x] AC2: `InstallIntegrationAssets` imports neither `crate::services` nor a filesystem API -> architecture checks passed. +- [x] AC3: Staging/write/removal/rename/cleanup I/O lives only in the filesystem outbound adapter -> adapter tests and architecture checks passed. +- [x] AC4: Optional-workflow asset filtering is byte-for-byte compatible -> embedded catalog parity tests passed. +- [x] AC5: Setup install flags produce identical files/paths/counts -> facade tests and four-target scratch smoke passed with 23/19/18 counts. +- [x] AC6: Existing target replaced with no-backup remove-then-rename -> adapter and facade replacement tests passed. +- [x] AC7: Absolute/`..` embedded asset paths rejected before staging write -> invalid-path adapter test passed. +- [x] AC8: Failed staging write/rename cleans up staging and surfaces recovery guidance -> cleanup tests passed. +- [x] AC9: `persist_integration_targets` still runs only after successful install -> source inspection confirmed unchanged call order. +- [x] AC10: Setup success/error message text and exit codes unchanged -> full CLI tests, facade tests, and target smoke output passed. +- [x] AC11: Legacy asset-installation implementation removed from inline `services::setup::install` -> legacy-function grep returned no matches. +- [x] AC12: `IntegrationAsset` uses `Cow<'static, [u8]>`, embedded assets use `Cow::Borrowed`, and owned bytes are preserved -> source inspection and owned-bytes test passed. +- [x] AC13: Installer preflight runs exactly once before catalog/install calls for `One` and `All` -> broader use-case filter passed the One/All preflight tests; source inspection confirmed the call precedes the loop. +- [x] AC14: Preflight errors prevent all catalog/install calls and preserve provenance -> preflight-error test passed. +- [x] AC15: Writability checking occurs only in filesystem `preflight` -> source inspection showed one call site in `preflight`. +- [x] AC16: Conflicting staging paths clean up staging without creating the destination -> deterministic staging-write cleanup test passed. +- [x] AC17: Facade covers every target with expected order, roots, counts, and representative files -> facade test passed. +- [x] AC18: Facade preserves optional-workflow selection -> facade test passed. +- [x] AC19: Facade replaces a target without a backup and removes a sentinel -> facade test passed. +- [x] AC20: Plan evidence cites facade tests and architecture documents Cow/preflight -> source inspection confirmed both requirements. ### Failed checks and follow-ups @@ -178,6 +280,13 @@ None. The change request fully specifies the domain model, port shapes, adapter ### Residual risks -- AC5 and AC10 rely on existing automated coverage plus the manual scratch-repository smoke run already recorded under T06 evidence; no independent manual run was repeated in this validation pass. +- None identified. +- Full validation remains incomplete until the Clippy diagnostics are repaired. + +### Retry + +After repairs, rerun: + +`/validate context/plans/install-integration-assets.md` From c968d50df7e5bf498fd4f63abf5ddc98b9802015 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 4 Aug 2026 12:03:42 +0200 Subject: [PATCH 5/5] cli: Migrate integration config persistence to hexagonal slice Extract repository-scoped integration configuration persistence from services::setup into a narrow IntegrationConfigRepository port, three application use cases (EnsureRepoConfig, LoadPersistedOptionalWorkflows, RecordIntegrationInstallation), and a filesystem outbound adapter owning .sce/config.json lifecycle, JSON merge, and serialization. IntegrationTarget gains config_id() for canonical target identifiers. Public setup functions remain compatibility facades preserving existing error context, ordering, and best-effort behavior. Co-authored-by: SCE --- .../integration_config_repository.rs | 426 ++++++++++++++++++ cli/src/adapters/outbound/filesystem/mod.rs | 1 + .../ports/integration_config_repository.rs | 30 ++ cli/src/application/ports/mod.rs | 1 + .../use_cases/ensure_repo_config.rs | 106 +++++ .../load_persisted_optional_workflows.rs | 116 +++++ cli/src/application/use_cases/mod.rs | 3 + .../record_integration_installation.rs | 177 ++++++++ cli/src/domain/integration/target.rs | 22 +- cli/src/services/setup/mod.rs | 315 +++++++------ context/architecture.md | 40 +- context/glossary.md | 4 + context/plans/install-integration-assets.md | 1 - ...pository-integration-config-persistence.md | 232 ++++++++++ .../sce/setup-repo-local-config-bootstrap.md | 5 +- 15 files changed, 1325 insertions(+), 154 deletions(-) create mode 100644 cli/src/adapters/outbound/filesystem/integration_config_repository.rs create mode 100644 cli/src/application/ports/integration_config_repository.rs create mode 100644 cli/src/application/use_cases/ensure_repo_config.rs create mode 100644 cli/src/application/use_cases/load_persisted_optional_workflows.rs create mode 100644 cli/src/application/use_cases/record_integration_installation.rs create mode 100644 context/plans/migrate-repository-integration-config-persistence.md diff --git a/cli/src/adapters/outbound/filesystem/integration_config_repository.rs b/cli/src/adapters/outbound/filesystem/integration_config_repository.rs new file mode 100644 index 00000000..c02da16c --- /dev/null +++ b/cli/src/adapters/outbound/filesystem/integration_config_repository.rs @@ -0,0 +1,426 @@ +//! `FilesystemIntegrationConfigRepository`: the `IntegrationConfigRepository` +//! outbound adapter that owns repo-local `.sce/config.json` lifecycle, JSON +//! parsing/merge, and serialization, porting persistence previously inline +//! in `services::setup`. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde_json::json; + +use crate::application::ports::integration_config_repository::IntegrationConfigRepository; +use crate::domain::integration::IntegrationTarget; +use crate::services::agent_trace::SCE_WEB_BASE_URL; +use crate::services::default_paths::RepoPaths; + +/// Canonical JSON payload for a newly bootstrapped repo-local +/// `.sce/config.json`. Contains only the `$schema` declaration pointing to +/// the SCE config JSON Schema. +fn repo_local_config_bootstrap_payload() -> String { + format!("{{\n \"$schema\": \"{SCE_WEB_BASE_URL}/config.json\"\n}}\n") +} + +fn read_config_document(config_file: &Path) -> Result { + let raw = fs::read_to_string(config_file) + .with_context(|| format!("Failed to read config file '{}'", config_file.display()))?; + + let document: serde_json::Value = serde_json::from_str(&raw).with_context(|| { + format!( + "Config file '{}' must contain valid JSON.", + config_file.display() + ) + })?; + + Ok(document) +} + +fn recorded_string_list(document: &serde_json::Value, field: &str) -> Vec { + document + .get("integrations") + .and_then(|integrations| integrations.get(field)) + .and_then(|value| value.as_array()) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +/// Owns repo-local integration configuration persistence directly on disk: +/// bootstrap, optional-workflow reads, and recording concrete installed +/// targets. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct FilesystemIntegrationConfigRepository; + +impl IntegrationConfigRepository for FilesystemIntegrationConfigRepository { + type Error = anyhow::Error; + + fn ensure_exists(&self, repository_root: &Path) -> Result<()> { + let repo_paths = RepoPaths::new(repository_root); + let config_file = repo_paths.sce_config_file(); + + if config_file.exists() { + return Ok(()); + } + + let sce_dir = repo_paths.sce_dir(); + fs::create_dir_all(&sce_dir).with_context(|| { + format!( + "Failed to create repo-local config directory '{}'", + sce_dir.display() + ) + })?; + + fs::write(&config_file, repo_local_config_bootstrap_payload()).with_context(|| { + format!( + "Failed to write repo-local config file '{}'", + config_file.display() + ) + })?; + + Ok(()) + } + + fn load_optional_workflows(&self, repository_root: &Path) -> Result> { + let config_file = RepoPaths::new(repository_root).sce_config_file(); + let document = read_config_document(&config_file)?; + + if !document.is_object() { + anyhow::bail!( + "Config file '{}' must contain a top-level JSON object.", + config_file.display() + ); + } + + Ok(recorded_string_list(&document, "optional_workflows")) + } + + fn record_installation( + &self, + repository_root: &Path, + targets: &[IntegrationTarget], + optional_workflows: &[String], + ) -> Result<()> { + let repo_paths = RepoPaths::new(repository_root); + let config_file = repo_paths.sce_config_file(); + + if !config_file.exists() { + self.ensure_exists(repository_root)?; + } + + let mut document = read_config_document(&config_file)?; + let mut existing_targets = recorded_string_list(&document, "target"); + + let config_obj = document.as_object_mut().with_context(|| { + format!( + "Config file '{}' must contain a top-level JSON object.", + config_file.display() + ) + })?; + + for target in targets { + let id = target.config_id().to_string(); + if !existing_targets.contains(&id) { + existing_targets.push(id); + } + } + + config_obj.insert( + "integrations".to_string(), + json!({ + "target": existing_targets, + "optional_workflows": optional_workflows, + }), + ); + + let updated = serde_json::to_string_pretty(&document).with_context(|| { + format!( + "Failed to serialize updated config for '{}'", + config_file.display() + ) + })? + "\n"; + + fs::write(&config_file, updated) + .with_context(|| format!("Failed to write config file '{}'", config_file.display()))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-filesystem-integration-config-repository-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + #[test] + fn ensure_exists_creates_a_missing_config_with_the_canonical_payload() { + let repo = unique_temp_dir("ensure-missing"); + let repository = FilesystemIntegrationConfigRepository; + + repository.ensure_exists(&repo).expect("ensure exists"); + + let config_file = RepoPaths::new(&repo).sce_config_file(); + let content = fs::read_to_string(&config_file).expect("read config"); + assert_eq!(content, repo_local_config_bootstrap_payload()); + assert!(content.ends_with('\n') && !content.ends_with("\n\n")); + } + + #[test] + fn ensure_exists_never_overwrites_an_existing_config() { + let repo = unique_temp_dir("ensure-existing"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "{\"sentinel\": true}\n").expect("seed config"); + + repository.ensure_exists(&repo).expect("ensure exists"); + + let content = fs::read_to_string(&config_file).expect("read config"); + assert_eq!(content, "{\"sentinel\": true}\n"); + } + + #[test] + fn record_installation_bootstraps_a_missing_config_before_recording() { + let repo = unique_temp_dir("record-missing"); + let repository = FilesystemIntegrationConfigRepository; + + repository + .record_installation(&repo, &[IntegrationTarget::Claude], &[]) + .expect("record installation"); + + let config_file = RepoPaths::new(&repo).sce_config_file(); + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + + assert_eq!( + document["$schema"], + json!(format!("{SCE_WEB_BASE_URL}/config.json")) + ); + assert_eq!(document["integrations"]["target"], json!(["claude"])); + assert_eq!(document["integrations"]["optional_workflows"], json!([])); + } + + #[test] + fn record_installation_preserves_unrelated_top_level_fields() { + let repo = unique_temp_dir("record-unrelated"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "{\"custom\": \"value\"}\n").expect("seed config"); + + repository + .record_installation(&repo, &[IntegrationTarget::OpenCode], &[]) + .expect("record installation"); + + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + assert_eq!(document["custom"], json!("value")); + } + + #[test] + fn record_installation_preserves_existing_target_order_and_dedups_new_targets() { + let repo = unique_temp_dir("record-order"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write( + &config_file, + "{\"integrations\": {\"target\": [\"claude\", \"pi\"]}}\n", + ) + .expect("seed config"); + + repository + .record_installation( + &repo, + &[IntegrationTarget::Pi, IntegrationTarget::OpenCode], + &[], + ) + .expect("record installation"); + + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + assert_eq!( + document["integrations"]["target"], + json!(["claude", "pi", "opencode"]) + ); + } + + #[test] + fn record_installation_preserves_unknown_existing_target_strings() { + let repo = unique_temp_dir("record-unknown"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write( + &config_file, + "{\"integrations\": {\"target\": [\"future-target\"]}}\n", + ) + .expect("seed config"); + + repository + .record_installation(&repo, &[IntegrationTarget::Claude], &[]) + .expect("record installation"); + + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + assert_eq!( + document["integrations"]["target"], + json!(["future-target", "claude"]) + ); + } + + #[test] + fn record_installation_replaces_optional_workflows_with_the_current_selection() { + let repo = unique_temp_dir("record-workflows"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write( + &config_file, + "{\"integrations\": {\"optional_workflows\": [\"stale\"]}}\n", + ) + .expect("seed config"); + + repository + .record_installation( + &repo, + &[IntegrationTarget::Claude], + &["research".to_string()], + ) + .expect("record installation"); + + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + assert_eq!( + document["integrations"]["optional_workflows"], + json!(["research"]) + ); + } + + #[test] + fn record_installation_returns_an_error_for_invalid_json() { + let repo = unique_temp_dir("record-invalid-json"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "not json").expect("seed config"); + + let error = repository + .record_installation(&repo, &[IntegrationTarget::Claude], &[]) + .unwrap_err(); + + assert!(error.to_string().contains("must contain valid JSON")); + } + + #[test] + fn record_installation_returns_an_error_for_a_non_object_top_level_value() { + let repo = unique_temp_dir("record-non-object"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "[]\n").expect("seed config"); + + let error = repository + .record_installation(&repo, &[IntegrationTarget::Claude], &[]) + .unwrap_err(); + + assert!(error + .to_string() + .contains("must contain a top-level JSON object")); + } + + #[test] + fn record_installation_writes_pretty_json_with_exactly_one_final_newline() { + let repo = unique_temp_dir("record-newline"); + let repository = FilesystemIntegrationConfigRepository; + + repository + .record_installation(&repo, &[IntegrationTarget::Claude], &[]) + .expect("record installation"); + + let config_file = RepoPaths::new(&repo).sce_config_file(); + let content = fs::read_to_string(&config_file).expect("read config"); + + assert!(content.ends_with('\n') && !content.ends_with("\n\n")); + assert!(content.contains("\n "), "expected pretty-printed JSON"); + } + + #[test] + fn load_optional_workflows_returns_the_recorded_workflows() { + let repo = unique_temp_dir("load-recorded"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write( + &config_file, + "{\"integrations\": {\"optional_workflows\": [\"research\", \"docs\"]}}\n", + ) + .expect("seed config"); + + let workflows = repository + .load_optional_workflows(&repo) + .expect("load optional workflows"); + + assert_eq!(workflows, vec!["research".to_string(), "docs".to_string()]); + } + + #[test] + fn load_optional_workflows_returns_empty_when_none_are_recorded() { + let repo = unique_temp_dir("load-empty"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "{}\n").expect("seed config"); + + let workflows = repository + .load_optional_workflows(&repo) + .expect("load optional workflows"); + + assert!(workflows.is_empty()); + } + + #[test] + fn load_optional_workflows_returns_an_error_when_the_config_is_missing() { + let repo = unique_temp_dir("load-missing"); + let repository = FilesystemIntegrationConfigRepository; + + let error = repository.load_optional_workflows(&repo).unwrap_err(); + + assert!(error.to_string().contains("Failed to read config file")); + } + + #[test] + fn load_optional_workflows_returns_an_error_for_invalid_json() { + let repo = unique_temp_dir("load-invalid-json"); + let repository = FilesystemIntegrationConfigRepository; + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "not json").expect("seed config"); + + let error = repository.load_optional_workflows(&repo).unwrap_err(); + + assert!(error.to_string().contains("must contain valid JSON")); + } +} diff --git a/cli/src/adapters/outbound/filesystem/mod.rs b/cli/src/adapters/outbound/filesystem/mod.rs index e0ce28bf..533b9ad8 100644 --- a/cli/src/adapters/outbound/filesystem/mod.rs +++ b/cli/src/adapters/outbound/filesystem/mod.rs @@ -1,4 +1,5 @@ //! Filesystem-backed outbound adapters. pub(crate) mod context_store; +pub(crate) mod integration_config_repository; pub(crate) mod integration_installer; diff --git a/cli/src/application/ports/integration_config_repository.rs b/cli/src/application/ports/integration_config_repository.rs new file mode 100644 index 00000000..82a87904 --- /dev/null +++ b/cli/src/application/ports/integration_config_repository.rs @@ -0,0 +1,30 @@ +//! `IntegrationConfigRepository` port: owns repo-local `.sce/config.json` +//! lifecycle for integration configuration, owned by an outbound adapter and +//! consumed by the repository-config use cases. + +use std::path::Path; + +use crate::domain::integration::IntegrationTarget; + +/// Owns repo-local integration configuration persistence: bootstrap, +/// optional-workflow reads, and recording concrete installed targets. +pub(crate) trait IntegrationConfigRepository { + type Error; + + /// Creates the repo-local config file with the canonical bootstrap + /// payload if it does not already exist; leaves an existing file + /// untouched. + fn ensure_exists(&self, repository_root: &Path) -> Result<(), Self::Error>; + + /// The optional workflows currently recorded in the repo-local config. + fn load_optional_workflows(&self, repository_root: &Path) -> Result, Self::Error>; + + /// Records the given concrete targets as installed and replaces the + /// recorded optional-workflow selection. + fn record_installation( + &self, + repository_root: &Path, + targets: &[IntegrationTarget], + optional_workflows: &[String], + ) -> Result<(), Self::Error>; +} diff --git a/cli/src/application/ports/mod.rs b/cli/src/application/ports/mod.rs index 0772b1b8..758aabf4 100644 --- a/cli/src/application/ports/mod.rs +++ b/cli/src/application/ports/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod context_store; pub(crate) mod integration_asset_catalog; +pub(crate) mod integration_config_repository; pub(crate) mod integration_installer; diff --git a/cli/src/application/use_cases/ensure_repo_config.rs b/cli/src/application/use_cases/ensure_repo_config.rs new file mode 100644 index 00000000..908d441a --- /dev/null +++ b/cli/src/application/use_cases/ensure_repo_config.rs @@ -0,0 +1,106 @@ +//! `EnsureRepoConfig` use case: ensures the repo-local integration +//! configuration file exists, delegating persistence to an injected +//! `IntegrationConfigRepository`. + +use std::path::PathBuf; + +use crate::application::ports::integration_config_repository::IntegrationConfigRepository; + +/// The repository root to ensure repo-local integration configuration +/// against. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EnsureRepoConfigRequest { + pub(crate) repository_root: PathBuf, +} + +/// Ensures the repo-local integration configuration file exists, via an +/// injected `IntegrationConfigRepository`. +pub(crate) struct EnsureRepoConfig { + repository: R, +} + +impl EnsureRepoConfig { + pub(crate) fn new(repository: R) -> Self { + Self { repository } + } + + pub(crate) fn execute(&self, request: &EnsureRepoConfigRequest) -> Result<(), R::Error> { + self.repository.ensure_exists(&request.repository_root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::path::Path; + + use crate::domain::integration::IntegrationTarget; + + #[derive(Default)] + struct FakeRepository { + ensure_exists_calls: RefCell>, + ensure_exists_error: Option<&'static str>, + } + + impl IntegrationConfigRepository for FakeRepository { + type Error = &'static str; + + fn ensure_exists(&self, repository_root: &Path) -> Result<(), Self::Error> { + self.ensure_exists_calls + .borrow_mut() + .push(repository_root.to_path_buf()); + + self.ensure_exists_error.map_or(Ok(()), Err) + } + + fn load_optional_workflows( + &self, + _repository_root: &Path, + ) -> Result, Self::Error> { + unreachable!("not exercised by EnsureRepoConfig") + } + + fn record_installation( + &self, + _repository_root: &Path, + _targets: &[IntegrationTarget], + _optional_workflows: &[String], + ) -> Result<(), Self::Error> { + unreachable!("not exercised by EnsureRepoConfig") + } + } + + #[test] + fn execute_delegates_to_ensure_exists_with_the_resolved_root() { + let repository = FakeRepository::default(); + let use_case = EnsureRepoConfig::new(repository); + let repository_root = PathBuf::from("/repo"); + + use_case + .execute(&EnsureRepoConfigRequest { + repository_root: repository_root.clone(), + }) + .unwrap(); + + assert_eq!( + use_case.repository.ensure_exists_calls.borrow().as_slice(), + [repository_root] + ); + } + + #[test] + fn execute_propagates_repository_errors() { + let repository = FakeRepository { + ensure_exists_calls: RefCell::new(Vec::new()), + ensure_exists_error: Some("ensure_exists failed"), + }; + let use_case = EnsureRepoConfig::new(repository); + + let result = use_case.execute(&EnsureRepoConfigRequest { + repository_root: PathBuf::from("/repo"), + }); + + assert_eq!(result, Err("ensure_exists failed")); + } +} diff --git a/cli/src/application/use_cases/load_persisted_optional_workflows.rs b/cli/src/application/use_cases/load_persisted_optional_workflows.rs new file mode 100644 index 00000000..b0ec5b3f --- /dev/null +++ b/cli/src/application/use_cases/load_persisted_optional_workflows.rs @@ -0,0 +1,116 @@ +//! `LoadPersistedOptionalWorkflows` use case: reads the optional workflows +//! currently recorded in repo-local integration configuration, delegating to +//! an injected `IntegrationConfigRepository`. + +use std::path::PathBuf; + +use crate::application::ports::integration_config_repository::IntegrationConfigRepository; + +/// The repository root to load persisted optional workflows from. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LoadPersistedOptionalWorkflowsRequest { + pub(crate) repository_root: PathBuf, +} + +/// Reads the optional workflows currently recorded in repo-local integration +/// configuration, via an injected `IntegrationConfigRepository`. +pub(crate) struct LoadPersistedOptionalWorkflows { + repository: R, +} + +impl LoadPersistedOptionalWorkflows { + pub(crate) fn new(repository: R) -> Self { + Self { repository } + } + + pub(crate) fn execute( + &self, + request: &LoadPersistedOptionalWorkflowsRequest, + ) -> Result, R::Error> { + self.repository + .load_optional_workflows(&request.repository_root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::path::Path; + + use crate::domain::integration::IntegrationTarget; + + struct FakeRepository { + load_optional_workflows_calls: RefCell>, + load_optional_workflows_result: Result, &'static str>, + } + + impl IntegrationConfigRepository for FakeRepository { + type Error = &'static str; + + fn ensure_exists(&self, _repository_root: &Path) -> Result<(), Self::Error> { + unreachable!("not exercised by LoadPersistedOptionalWorkflows") + } + + fn load_optional_workflows( + &self, + repository_root: &Path, + ) -> Result, Self::Error> { + self.load_optional_workflows_calls + .borrow_mut() + .push(repository_root.to_path_buf()); + + self.load_optional_workflows_result.clone() + } + + fn record_installation( + &self, + _repository_root: &Path, + _targets: &[IntegrationTarget], + _optional_workflows: &[String], + ) -> Result<(), Self::Error> { + unreachable!("not exercised by LoadPersistedOptionalWorkflows") + } + } + + #[test] + fn execute_returns_the_repositorys_workflows_unchanged() { + let repository = FakeRepository { + load_optional_workflows_calls: RefCell::new(Vec::new()), + load_optional_workflows_result: Ok(vec!["research".to_string(), "docs".to_string()]), + }; + let use_case = LoadPersistedOptionalWorkflows::new(repository); + let repository_root = PathBuf::from("/repo"); + + let workflows = use_case + .execute(&LoadPersistedOptionalWorkflowsRequest { + repository_root: repository_root.clone(), + }) + .unwrap(); + + assert_eq!(workflows, vec!["research".to_string(), "docs".to_string()]); + assert_eq!( + use_case + .repository + .load_optional_workflows_calls + .borrow() + .as_slice(), + [repository_root] + ); + } + + #[test] + fn execute_propagates_repository_errors_unchanged() { + let repository = FakeRepository { + load_optional_workflows_calls: RefCell::new(Vec::new()), + load_optional_workflows_result: Err("load failed"), + }; + let use_case = LoadPersistedOptionalWorkflows::new(repository); + + let result = use_case.execute(&LoadPersistedOptionalWorkflowsRequest { + repository_root: PathBuf::from("/repo"), + }); + + assert_eq!(result, Err("load failed")); + } +} diff --git a/cli/src/application/use_cases/mod.rs b/cli/src/application/use_cases/mod.rs index d339cc98..88d4e748 100644 --- a/cli/src/application/use_cases/mod.rs +++ b/cli/src/application/use_cases/mod.rs @@ -1,4 +1,7 @@ //! Use cases: application-specific orchestration of domain and ports. pub(crate) mod ensure_context_baseline; +pub(crate) mod ensure_repo_config; pub(crate) mod install_integration_assets; +pub(crate) mod load_persisted_optional_workflows; +pub(crate) mod record_integration_installation; diff --git a/cli/src/application/use_cases/record_integration_installation.rs b/cli/src/application/use_cases/record_integration_installation.rs new file mode 100644 index 00000000..ee3a12b2 --- /dev/null +++ b/cli/src/application/use_cases/record_integration_installation.rs @@ -0,0 +1,177 @@ +//! `RecordIntegrationInstallation` use case: expands a target selection into +//! concrete targets and records their installation, delegating to an injected +//! `IntegrationConfigRepository`. + +use std::path::PathBuf; + +use crate::application::ports::integration_config_repository::IntegrationConfigRepository; +use crate::domain::integration::IntegrationTargetSelection; + +/// The repository root, target selection, and optional-workflow selection to +/// record as installed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RecordIntegrationInstallationRequest { + pub(crate) repository_root: PathBuf, + pub(crate) selection: IntegrationTargetSelection, + pub(crate) optional_workflows: Vec, +} + +/// Expands a target selection into concrete targets and records their +/// installation, via an injected `IntegrationConfigRepository`. +pub(crate) struct RecordIntegrationInstallation { + repository: R, +} + +impl RecordIntegrationInstallation { + pub(crate) fn new(repository: R) -> Self { + Self { repository } + } + + pub(crate) fn execute( + &self, + request: &RecordIntegrationInstallationRequest, + ) -> Result<(), R::Error> { + self.repository.record_installation( + &request.repository_root, + request.selection.targets(), + &request.optional_workflows, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::path::Path; + + use crate::domain::integration::IntegrationTarget; + + type RecordInstallationCall = (PathBuf, Vec, Vec); + + #[derive(Default)] + struct FakeRepository { + record_installation_calls: RefCell>, + record_installation_error: Option<&'static str>, + } + + impl IntegrationConfigRepository for FakeRepository { + type Error = &'static str; + + fn ensure_exists(&self, _repository_root: &Path) -> Result<(), Self::Error> { + unreachable!("not exercised by RecordIntegrationInstallation") + } + + fn load_optional_workflows( + &self, + _repository_root: &Path, + ) -> Result, Self::Error> { + unreachable!("not exercised by RecordIntegrationInstallation") + } + + fn record_installation( + &self, + repository_root: &Path, + targets: &[IntegrationTarget], + optional_workflows: &[String], + ) -> Result<(), Self::Error> { + self.record_installation_calls.borrow_mut().push(( + repository_root.to_path_buf(), + targets.to_vec(), + optional_workflows.to_vec(), + )); + + self.record_installation_error.map_or(Ok(()), Err) + } + } + + #[test] + fn one_selection_records_a_single_concrete_target() { + let repository = FakeRepository::default(); + let use_case = RecordIntegrationInstallation::new(repository); + let repository_root = PathBuf::from("/repo"); + let optional_workflows = vec!["research".to_string()]; + + use_case + .execute(&RecordIntegrationInstallationRequest { + repository_root: repository_root.clone(), + selection: IntegrationTargetSelection::One(IntegrationTarget::Claude), + optional_workflows: optional_workflows.clone(), + }) + .unwrap(); + + let calls = use_case.repository.record_installation_calls.borrow(); + assert_eq!( + calls.as_slice(), + [( + repository_root, + vec![IntegrationTarget::Claude], + optional_workflows + )] + ); + } + + #[test] + fn all_selection_records_every_target_in_order() { + let repository = FakeRepository::default(); + let use_case = RecordIntegrationInstallation::new(repository); + let repository_root = PathBuf::from("/repo"); + + use_case + .execute(&RecordIntegrationInstallationRequest { + repository_root: repository_root.clone(), + selection: IntegrationTargetSelection::All, + optional_workflows: Vec::new(), + }) + .unwrap(); + + let calls = use_case.repository.record_installation_calls.borrow(); + assert_eq!( + calls.as_slice(), + [( + repository_root, + vec![ + IntegrationTarget::OpenCode, + IntegrationTarget::Claude, + IntegrationTarget::Pi, + ], + Vec::new() + )] + ); + } + + #[test] + fn execute_forwards_the_optional_workflow_slice_unchanged() { + let repository = FakeRepository::default(); + let use_case = RecordIntegrationInstallation::new(repository); + let optional_workflows = vec!["research".to_string(), "docs".to_string()]; + + use_case + .execute(&RecordIntegrationInstallationRequest { + repository_root: PathBuf::from("/repo"), + selection: IntegrationTargetSelection::One(IntegrationTarget::OpenCode), + optional_workflows: optional_workflows.clone(), + }) + .unwrap(); + + let calls = use_case.repository.record_installation_calls.borrow(); + assert_eq!(calls[0].2, optional_workflows); + } + + #[test] + fn execute_propagates_repository_errors() { + let repository = FakeRepository { + record_installation_calls: RefCell::new(Vec::new()), + record_installation_error: Some("record_installation failed"), + }; + let use_case = RecordIntegrationInstallation::new(repository); + + let result = use_case.execute(&RecordIntegrationInstallationRequest { + repository_root: PathBuf::from("/repo"), + selection: IntegrationTargetSelection::All, + optional_workflows: Vec::new(), + }); + + assert_eq!(result, Err("record_installation failed")); + } +} diff --git a/cli/src/domain/integration/target.rs b/cli/src/domain/integration/target.rs index 4ed5ac7b..c8a17491 100644 --- a/cli/src/domain/integration/target.rs +++ b/cli/src/domain/integration/target.rs @@ -2,7 +2,6 @@ /// A concrete integration target an outbound adapter may install assets for. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[allow(dead_code)] // consumed starting with the IntegrationAssetCatalog/IntegrationInstaller ports (T02) pub(crate) enum IntegrationTarget { OpenCode, Claude, @@ -15,6 +14,18 @@ const ALL_TARGETS: [IntegrationTarget; 3] = [ IntegrationTarget::Pi, ]; +impl IntegrationTarget { + /// The canonical `integrations.target` identifier persisted for this + /// target in repo-local `.sce/config.json`. + pub(crate) fn config_id(self) -> &'static str { + match self { + Self::OpenCode => "opencode", + Self::Claude => "claude", + Self::Pi => "pi", + } + } +} + /// A caller's target selection: a single target, or all of them. /// /// `All` is expanded into concrete `IntegrationTarget` values via @@ -22,7 +33,6 @@ const ALL_TARGETS: [IntegrationTarget; 3] = [ /// invoked, so no outbound adapter is ever called with a meta value /// representing "all targets". #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[allow(dead_code)] // consumed starting with the IntegrationAssetCatalog/IntegrationInstaller ports (T02) pub(crate) enum IntegrationTargetSelection { One(IntegrationTarget), All, @@ -33,7 +43,6 @@ impl IntegrationTargetSelection { /// /// `One` yields the single wrapped target; `All` yields /// `[OpenCode, Claude, Pi]` in that order. - #[allow(dead_code)] // consumed starting with the InstallIntegrationAssets use case (T03) pub(crate) fn targets(&self) -> &[IntegrationTarget] { match self { Self::One(target) => std::slice::from_ref(target), @@ -66,4 +75,11 @@ mod tests { ] ); } + + #[test] + fn config_id_maps_each_concrete_target() { + assert_eq!(IntegrationTarget::OpenCode.config_id(), "opencode"); + assert_eq!(IntegrationTarget::Claude.config_id(), "claude"); + assert_eq!(IntegrationTarget::Pi.config_id(), "pi"); + } } diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index d9b18439..f0522c6c 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -1,24 +1,25 @@ use anyhow::{bail, Context, Result}; -use serde_json::json; use std::{ fs, path::{Path, PathBuf}, }; +use crate::adapters::outbound::filesystem::integration_config_repository::FilesystemIntegrationConfigRepository; +use crate::application::use_cases::ensure_repo_config::{ + EnsureRepoConfig, EnsureRepoConfigRequest, +}; +use crate::application::use_cases::load_persisted_optional_workflows::{ + LoadPersistedOptionalWorkflows, LoadPersistedOptionalWorkflowsRequest, +}; +use crate::application::use_cases::record_integration_installation::{ + RecordIntegrationInstallation, RecordIntegrationInstallationRequest, +}; +use crate::domain::integration::{IntegrationTarget, IntegrationTargetSelection}; +use crate::services::default_paths; use crate::services::style::{label, success, value}; -use crate::services::{default_paths, default_paths::RepoPaths}; pub mod command; -/// Canonical JSON payload for a newly bootstrapped repo-local `.sce/config.json`. -/// Contains only the `$schema` declaration pointing to the SCE config JSON Schema. -fn repo_local_config_bootstrap_payload() -> String { - format!( - "{{\n \"$schema\": \"{}/config.json\"\n}}\n", - crate::services::agent_trace::SCE_WEB_BASE_URL - ) -} - pub const NAME: &str = "setup"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -379,24 +380,12 @@ pub fn run_setup_for_mode( /// The optional workflows recorded in repo-local `.sce/config.json`, or an empty /// selection when the file is absent, unreadable, or records none. pub fn persisted_optional_workflows(repository_root: &Path) -> Vec { - use crate::services::config::schema::parse_file_config; - use crate::services::config::ConfigPathSource; - - let config_path = RepoPaths::new(repository_root).sce_config_file(); + let use_case = LoadPersistedOptionalWorkflows::new(FilesystemIntegrationConfigRepository); - let Ok(raw) = fs::read_to_string(&config_path) else { - return Vec::new(); - }; - - let Ok(config) = - parse_file_config(&raw, &config_path, ConfigPathSource::DefaultDiscoveredLocal) - else { - return Vec::new(); - }; - - config - .integrations - .map(|integrations| integrations.value.optional_workflows) + use_case + .execute(&LoadPersistedOptionalWorkflowsRequest { + repository_root: repository_root.to_path_buf(), + }) .unwrap_or_default() } @@ -412,29 +401,11 @@ pub fn ensure_git_repository(directory: &Path) -> Result { /// Creates the `.sce/` parent directory as needed, then writes the canonical /// schema-only JSON payload. If the file already exists, it is left untouched. pub fn bootstrap_repo_local_config(repository_root: &Path) -> Result<()> { - let repo_paths = RepoPaths::new(repository_root); - let config_file = repo_paths.sce_config_file(); - - if config_file.exists() { - return Ok(()); - } + let use_case = EnsureRepoConfig::new(FilesystemIntegrationConfigRepository); - let sce_dir = repo_paths.sce_dir(); - fs::create_dir_all(&sce_dir).with_context(|| { - format!( - "Failed to create repo-local config directory '{}'", - sce_dir.display() - ) - })?; - - fs::write(&config_file, repo_local_config_bootstrap_payload()).with_context(|| { - format!( - "Failed to write repo-local config file '{}'", - config_file.display() - ) - })?; - - Ok(()) + use_case.execute(&EnsureRepoConfigRequest { + repository_root: repository_root.to_path_buf(), + }) } /// Creates the baseline durable-context tree additively. @@ -595,14 +566,8 @@ pub fn install_embedded_setup_assets( use crate::application::use_cases::install_integration_assets::{ InstallIntegrationAssets, InstallIntegrationAssetsError, }; - use crate::domain::integration::{IntegrationTarget, IntegrationTargetSelection}; - let selection = match target { - SetupTarget::OpenCode => IntegrationTargetSelection::One(IntegrationTarget::OpenCode), - SetupTarget::Claude => IntegrationTargetSelection::One(IntegrationTarget::Claude), - SetupTarget::Pi => IntegrationTargetSelection::One(IntegrationTarget::Pi), - SetupTarget::All => IntegrationTargetSelection::All, - }; + let selection = integration_target_selection_for(target); let use_case = InstallIntegrationAssets::new( EmbeddedIntegrationAssetCatalog, @@ -629,11 +594,7 @@ pub fn install_embedded_setup_assets( Ok(SetupInstallOutcome { target_results }) } -fn setup_target_for_integration_target( - target: crate::domain::integration::IntegrationTarget, -) -> SetupTarget { - use crate::domain::integration::IntegrationTarget; - +fn setup_target_for_integration_target(target: IntegrationTarget) -> SetupTarget { match target { IntegrationTarget::OpenCode => SetupTarget::OpenCode, IntegrationTarget::Claude => SetupTarget::Claude, @@ -685,16 +646,14 @@ pub(crate) fn concrete_targets_for(target: SetupTarget) -> &'static [SetupTarget } } -/// Convert a concrete [`SetupTarget`] (not `All`) to its canonical -/// `integrations.target` string representation. -fn integration_target_id_str(target: SetupTarget) -> &'static str { +/// Convert a [`SetupTarget`] to the domain [`IntegrationTargetSelection`] it +/// represents, expanding `All` into every concrete target. +fn integration_target_selection_for(target: SetupTarget) -> IntegrationTargetSelection { match target { - SetupTarget::OpenCode => "opencode", - SetupTarget::Claude => "claude", - SetupTarget::Pi => "pi", - SetupTarget::All => { - unreachable!("integration_target_id_str must not be called with meta targets") - } + SetupTarget::OpenCode => IntegrationTargetSelection::One(IntegrationTarget::OpenCode), + SetupTarget::Claude => IntegrationTargetSelection::One(IntegrationTarget::Claude), + SetupTarget::Pi => IntegrationTargetSelection::One(IntegrationTarget::Pi), + SetupTarget::All => IntegrationTargetSelection::All, } } @@ -710,76 +669,13 @@ pub fn persist_integration_targets( target: SetupTarget, selected_optional_workflows: &[String], ) -> Result<()> { - let repo_paths = RepoPaths::new(repository_root); - let config_file = repo_paths.sce_config_file(); - - // Read existing config or start with bootstrap payload. - let raw = if config_file.exists() { - fs::read_to_string(&config_file) - .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? - } else { - bootstrap_repo_local_config(repository_root)?; - fs::read_to_string(&config_file) - .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? - }; - - let mut config: serde_json::Value = serde_json::from_str(&raw).with_context(|| { - format!( - "Config file '{}' must contain valid JSON.", - config_file.display() - ) - })?; - - let config_obj = config.as_object_mut().with_context(|| { - format!( - "Config file '{}' must contain a top-level JSON object.", - config_file.display() - ) - })?; - - // Collect existing integration target values, if any. - let mut existing_targets: Vec = config_obj - .get("integrations") - .and_then(|i| i.get("target")) - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - // Add new concrete targets (expanding All), deduping as we go. - let new_targets = concrete_targets_for(target); - for concrete in new_targets { - let id_str = integration_target_id_str(*concrete); - let id_owned = id_str.to_string(); - if !existing_targets.contains(&id_owned) { - existing_targets.push(id_owned); - } - } - - // Write the merged integrations block back. The optional-workflow selection - // resolved for this run replaces any previously recorded selection. - config_obj.insert( - "integrations".to_string(), - json!({ - "target": existing_targets, - "optional_workflows": selected_optional_workflows, - }), - ); - - let updated = serde_json::to_string_pretty(&config).with_context(|| { - format!( - "Failed to serialize updated config for '{}'", - config_file.display() - ) - })? + "\n"; - - fs::write(&config_file, updated) - .with_context(|| format!("Failed to write config file '{}'", config_file.display()))?; + let use_case = RecordIntegrationInstallation::new(FilesystemIntegrationConfigRepository); - Ok(()) + use_case.execute(&RecordIntegrationInstallationRequest { + repository_root: repository_root.to_path_buf(), + selection: integration_target_selection_for(target), + optional_workflows: selected_optional_workflows.to_vec(), + }) } mod install { @@ -1461,7 +1357,7 @@ mod tests { use crate::command_surface; use crate::services::command_registry::CommandRegistry; use crate::services::command_registry::RuntimeCommand; - use crate::services::default_paths::InstallTargetPaths; + use crate::services::default_paths::{InstallTargetPaths, RepoPaths}; use crate::services::parse::command_runtime::parse_runtime_command; fn options_with(mutate: impl FnOnce(&mut SetupCliOptions)) -> SetupCliOptions { @@ -1712,11 +1608,6 @@ mod tests { ); } - #[test] - fn integration_target_id_str_maps_pi() { - assert_eq!(integration_target_id_str(SetupTarget::Pi), "pi"); - } - /// Every optional workflow selected, so filtering drops nothing. fn every_optional_workflow() -> Vec { super::OPTIONAL_WORKFLOWS @@ -1890,4 +1781,136 @@ mod tests { let _ = fs::remove_dir_all(&repo); } + + #[test] + fn bootstrap_repo_local_config_facade_creates_a_missing_config() { + let repo = unique_temp_dir("bootstrap-facade-missing"); + + bootstrap_repo_local_config(&repo).expect("bootstrap should succeed"); + + let config_file = RepoPaths::new(&repo).sce_config_file(); + assert!(config_file.exists()); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn persisted_optional_workflows_facade_returns_the_recorded_selection() { + let repo = unique_temp_dir("persisted-workflows-present"); + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write( + &config_file, + "{\"integrations\": {\"optional_workflows\": [\"research\"]}}\n", + ) + .expect("seed config"); + + assert_eq!( + persisted_optional_workflows(&repo), + vec!["research".to_string()] + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn persisted_optional_workflows_facade_returns_an_empty_selection_when_config_is_missing() { + let repo = unique_temp_dir("persisted-workflows-missing"); + + assert!(persisted_optional_workflows(&repo).is_empty()); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn persisted_optional_workflows_facade_returns_an_empty_selection_when_config_is_invalid() { + let repo = unique_temp_dir("persisted-workflows-invalid"); + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "not json").expect("seed invalid config"); + + assert!(persisted_optional_workflows(&repo).is_empty()); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn persist_integration_targets_facade_preserves_unrelated_fields_and_records_all_targets() { + use serde_json::json; + + let repo = unique_temp_dir("persist-facade-all"); + let config_file = RepoPaths::new(&repo).sce_config_file(); + fs::create_dir_all(config_file.parent().unwrap()).expect("seed sce dir"); + fs::write(&config_file, "{\"custom\": \"value\"}\n").expect("seed config"); + + persist_integration_targets(&repo, SetupTarget::All, &[]).expect("persist should succeed"); + + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + assert_eq!(document["custom"], json!("value")); + assert_eq!( + document["integrations"]["target"], + json!(["opencode", "claude", "pi"]) + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn run_setup_for_mode_installs_then_persists_configuration_after_a_successful_install() { + use serde_json::json; + + let repo = unique_temp_dir("run-setup-success"); + let optional_workflows = vec!["research".to_string()]; + + let message = run_setup_for_mode( + &repo, + SetupMode::NonInteractive(SetupTarget::Claude), + Some(&optional_workflows), + ) + .expect("run_setup_for_mode should succeed"); + assert!(message.contains("Setup completed successfully.")); + + let config_file = RepoPaths::new(&repo).sce_config_file(); + let document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_file).expect("read config")) + .expect("valid json"); + assert_eq!(document["integrations"]["target"], json!(["claude"])); + assert_eq!( + document["integrations"]["optional_workflows"], + json!(["research"]) + ); + + let destination = InstallTargetPaths::new(&repo).claude_target_dir(); + assert!(destination.join("commands/next-task.md").is_file()); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn run_setup_for_mode_leaves_no_recorded_target_when_installation_fails() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let repository_root = std::env::temp_dir().join(format!( + "sce-setup-context-run-setup-install-fails-{}-{nonce}", + std::process::id() + )); + fs::write(&repository_root, b"not a directory") + .expect("seed a non-directory repository root"); + + let result = run_setup_for_mode( + &repository_root, + SetupMode::NonInteractive(SetupTarget::Claude), + Some(&[]), + ); + + assert!(result.is_err()); + let config_file = RepoPaths::new(&repository_root).sce_config_file(); + assert!(!config_file.exists()); + + let _ = fs::remove_file(&repository_root); + } } diff --git a/context/architecture.md b/context/architecture.md index 0de642d0..0722e3c4 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -188,6 +188,44 @@ than sharing the ones this slice moved into the filesystem adapter, so hook installation behavior is untouched by this migration. `composition::run` is not yet wired through this slice either. +The third landed slice is `sce setup`'s repository-scoped integration +configuration persistence (`.sce/config.json` bootstrap, optional-workflow +reads, and installed-target recording). `IntegrationTarget::config_id(self) +-> &'static str` (`cli/src/domain/integration/target.rs`) is the canonical +`integrations.target` identifier (`opencode`/`claude`/`pi`) for a concrete +target. `cli/src/application/ports/integration_config_repository.rs` defines +the narrow `IntegrationConfigRepository` port (`ensure_exists`, +`load_optional_workflows`, `record_installation`), and three thin use cases — +`EnsureRepoConfig`, `LoadPersistedOptionalWorkflows`, and +`RecordIntegrationInstallation` +(`cli/src/application/use_cases/{ensure_repo_config,load_persisted_optional_workflows,record_integration_installation}.rs`) +— delegate to it. As with the second slice, `RecordIntegrationInstallation` +is the only place `IntegrationTargetSelection::All` is expanded (via +`targets()`) before the port is called, so the adapter is never invoked with +a meta "all targets" value; a `LoadPersistedOptionalWorkflows` error is +returned to its caller unchanged rather than defaulted. +`cli/src/adapters/outbound/filesystem/integration_config_repository.rs`'s +`FilesystemIntegrationConfigRepository` implements the port directly against +`.sce/config.json`: it represents the document as `serde_json::Value` rather +than a strict typed struct — preserving unrelated top-level fields and +unknown existing `integrations.target` strings — merges new concrete targets +into `integrations.target` (deduplicated, existing order preserved), +replaces `integrations.optional_workflows` with the current selection, and +writes pretty JSON with exactly one trailing newline. +`services::setup::{bootstrap_repo_local_config, persisted_optional_workflows, +persist_integration_targets}` are now compatibility facades: each constructs +the filesystem adapter and runs the matching use case, preserving their +existing signatures, call-site order, and output. Only the +`persisted_optional_workflows` facade defaults a repository error (missing +file, unreadable file, invalid JSON, non-object top-level value) to an empty +selection; the use case and adapter otherwise propagate errors unchanged. +`services::setup::run_setup_for_mode` is unmigrated and unchanged: it still +resolves the optional-workflow selection, installs assets, then persists +configuration only after a successful install, through these facades. +`composition::run` is not yet wired through this slice either. See +`context/sce/setup-repo-local-config-bootstrap.md` for the full behavior +contract this slice preserves. + ```mermaid flowchart LR subgraph adapters["adapters"] @@ -262,7 +300,7 @@ the `cli-architecture` check. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering. Active hook runtime, setup/lifecycle storage, and trace status/list/shell flows resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator stages embedded files and uses a unified remove-and-replace policy (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure and no backup artifact creation), and formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same remove-and-replace policy (removing existing hooks before swapping staged content, with deterministic recovery guidance on swap failure). After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. -- `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization and required-hook installation (including its own hook-scoped staging/swap and filesystem safety guards), while the inline `prompt` module owns interactive target selection and prompt styling. Embedded integration-asset staging/swap install behavior moved out of this module into the `InstallIntegrationAssets` vertical slice (`cli/src/adapters/outbound/filesystem/integration_installer.rs`); `install_embedded_setup_assets` in this file is a facade over that slice. +- `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization and required-hook installation (including its own hook-scoped staging/swap and filesystem safety guards), while the inline `prompt` module owns interactive target selection and prompt styling. Embedded integration-asset staging/swap install behavior moved out of this module into the `InstallIntegrationAssets` vertical slice (`cli/src/adapters/outbound/filesystem/integration_installer.rs`); `install_embedded_setup_assets` in this file is a facade over that slice. Repo-local `.sce/config.json` bootstrap, optional-workflow reads, and installed-target recording moved out of this module into the repository integration-configuration vertical slice (`cli/src/adapters/outbound/filesystem/integration_config_repository.rs`); `bootstrap_repo_local_config`, `persisted_optional_workflows`, and `persist_integration_targets` in this file are facades over that slice. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Checkout DB discovery no longer lives in `doctor`; it moved to the `trace` group (`sce trace db list`) in `cli/src/services/trace/`. Report fact collection preserves environment/repository/hook/integration display data and checkout identity facts, while service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. diff --git a/context/glossary.md b/context/glossary.md index ba0eac14..02ad42bf 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -167,6 +167,10 @@ - `InstallIntegrationAssets`: Application use case in `cli/src/application/use_cases/install_integration_assets.rs` that expands a `IntegrationTargetSelection` via `targets()`, then for each concrete target calls `IntegrationAssetCatalog::assets_for` followed by `IntegrationInstaller::install`, short-circuiting on the first port error (`InstallIntegrationAssetsError::{Catalog, Installer}`) and returning an `InstallIntegrationAssetsReport`. Generic over both port types, with no dependency on `crate::services` or `crate::adapters`. - `EmbeddedIntegrationAssetCatalog`: Outbound adapter in `cli/src/adapters/outbound/assets/embedded_integration_assets.rs` implementing `IntegrationAssetCatalog` by delegating to `services::setup::iter_embedded_assets_for_setup_target_with_selection` and converting each `EmbeddedAsset` into a domain `IntegrationAsset`. - `FilesystemIntegrationInstaller`: Outbound adapter in `cli/src/adapters/outbound/filesystem/integration_installer.rs` implementing `IntegrationInstaller`: stages assets into a unique staging directory, rejects absolute or `..`-containing relative paths before writing, removes an existing destination without a backup, renames staging into place, and on staging-write or rename failure cleans up the staging path and returns the existing recovery-guidance text. +- `repository integration-configuration vertical slice`: Internal hexagonal setup capability owning repo-local `.sce/config.json` bootstrap, optional-workflow reads, and installed-target recording — see `IntegrationConfigRepository`, `EnsureRepoConfig`, `LoadPersistedOptionalWorkflows`, `RecordIntegrationInstallation`, and `FilesystemIntegrationConfigRepository` — behind the `bootstrap_repo_local_config`, `persisted_optional_workflows`, and `persist_integration_targets` compatibility facades in `cli/src/services/setup/mod.rs`. `IntegrationTarget::config_id(self) -> &'static str` (`cli/src/domain/integration/target.rs`) supplies the canonical `integrations.target` identifier (`opencode`/`claude`/`pi`) for a concrete target. +- `IntegrationConfigRepository`: Application port in `cli/src/application/ports/integration_config_repository.rs` — a trait with an associated `Error` type and `ensure_exists(&self, repository_root: &Path) -> Result<(), Self::Error>`, `load_optional_workflows(&self, repository_root: &Path) -> Result, Self::Error>`, and `record_installation(&self, repository_root: &Path, targets: &[IntegrationTarget], optional_workflows: &[String]) -> Result<(), Self::Error>`. Implemented by `FilesystemIntegrationConfigRepository` and injected into `EnsureRepoConfig`, `LoadPersistedOptionalWorkflows`, and `RecordIntegrationInstallation`. +- `EnsureRepoConfig` / `LoadPersistedOptionalWorkflows` / `RecordIntegrationInstallation`: Application use cases in `cli/src/application/use_cases/{ensure_repo_config,load_persisted_optional_workflows,record_integration_installation}.rs`. `EnsureRepoConfig::execute` delegates to `ensure_exists`. `LoadPersistedOptionalWorkflows::execute` returns the repository's `Vec`/error unchanged. `RecordIntegrationInstallation::execute` expands its request's `IntegrationTargetSelection` via `targets()` — the only place `All` is expanded before this port is called — and forwards the concrete slice plus the optional-workflow selection to one `record_installation` call. Each is generic over its port type, with no dependency on `crate::services` or `crate::adapters`. +- `FilesystemIntegrationConfigRepository`: Outbound adapter in `cli/src/adapters/outbound/filesystem/integration_config_repository.rs` implementing `IntegrationConfigRepository` directly against `.sce/config.json`: represents the document as `serde_json::Value` (not a strict typed struct) so unrelated top-level fields and unknown existing `integrations.target` strings survive; `ensure_exists` writes the canonical schema-only bootstrap payload only when the file is missing; `record_installation` bootstraps a missing file first, then merges deduplicated concrete target ids into `integrations.target` (existing order preserved) and replaces `integrations.optional_workflows` with the current selection, writing pretty JSON with exactly one trailing newline. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `conversation-trace` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id` values plus direct `model_id` and `tool_version` values (session-model fallback was removed in the `remove-session-models-direct-claude-model-id` plan). - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command`: `sce sync` has no command wiring and no `cli/src/services/sync.rs` module in the current runtime. Local DB initialization and health ownership are split between setup and doctor instead. diff --git a/context/plans/install-integration-assets.md b/context/plans/install-integration-assets.md index 70c88146..e0c5f9c4 100644 --- a/context/plans/install-integration-assets.md +++ b/context/plans/install-integration-assets.md @@ -281,7 +281,6 @@ None. The change request fully specifies the domain model, port shapes, adapter ### Residual risks - None identified. -- Full validation remains incomplete until the Clippy diagnostics are repaired. ### Retry diff --git a/context/plans/migrate-repository-integration-config-persistence.md b/context/plans/migrate-repository-integration-config-persistence.md new file mode 100644 index 00000000..809cba48 --- /dev/null +++ b/context/plans/migrate-repository-integration-config-persistence.md @@ -0,0 +1,232 @@ +# Plan: migrate-repository-integration-config-persistence + +## Change summary + +Extract repository-scoped integration configuration persistence from +`services::setup` into the CLI's internal hexagonal layers without moving +`run_setup_for_mode`. The slice adds a narrow `IntegrationConfigRepository` +port, three application use cases, and a filesystem outbound adapter that owns +`.sce/config.json` lifecycle, JSON parsing/merge behavior, and serialization. + +The existing public setup functions remain compatibility facades. Their +callers, error context, setup ordering, optional-workflow best-effort behavior, +and successful-install-before-persistence contract remain unchanged. The +adapter will preserve the current repository-config compatibility semantics +without deserializing the full document into a strict replacement struct. + +## Acceptance criteria + +- [x] AC1: Repository configuration operations are represented by the narrow + `IntegrationConfigRepository` port, and application code depends only on the + port and existing domain types; no application module depends on + `services`, filesystem APIs, or JSON APIs. + - Validate: `nix develop -c ./scripts/check-cli-architecture.sh`; inspect the + port and use-case imports and run the architecture flake check through + `nix flake check`. +- [x] AC2: `IntegrationTarget::config_id()` returns `opencode`, `claude`, and + `pi`, while the persistence port accepts only concrete + `IntegrationTarget` values; `All` is expanded in the record-installation use + case in `OpenCode`, `Claude`, `Pi` order before the repository is called. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml integration_config_repository` and + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml record_integration_installation`. +- [x] AC3: `FilesystemIntegrationConfigRepository::ensure_exists` creates a + missing `.sce/config.json` with the canonical schema-only payload and final + newline, but never overwrites an existing file. + - Validate: adapter tests for missing and existing configuration pass through + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::integration_config_repository`. +- [x] AC4: The filesystem adapter preserves the current JSON compatibility + semantics: unrelated top-level fields and unknown existing integration target + strings survive recording; existing target order is retained; newly selected + concrete target IDs are appended and deduplicated; and + `optional_workflows` is replaced by the current selection. + - Validate: the adapter merge tests in + `cli/src/adapters/outbound/filesystem/integration_config_repository.rs` + cover unrelated fields, order, deduplication, unknown strings, and + workflow replacement. +- [x] AC5: Invalid JSON and non-object top-level JSON values return the current + stable errors from the repository adapter, while serialized successful output + is pretty JSON with exactly one final newline. + - Validate: adapter tests assert the existing error text and inspect the + written bytes for the final-newline contract. +- [x] AC6: `EnsureRepoConfig` delegates to `ensure_exists`, + `LoadPersistedOptionalWorkflows` returns repository errors unchanged, and + `RecordIntegrationInstallation` forwards the workflow slice unchanged while + propagating repository errors. + - Validate: focused use-case tests with fake repositories prove delegation, + `All` expansion, single-target behavior, unchanged workflow forwarding, and + error propagation. +- [x] AC7: The public legacy facades + `bootstrap_repo_local_config`, `persisted_optional_workflows`, and + `persist_integration_targets` construct and invoke the new use cases while + preserving legacy error context and best-effort behavior. `services::setup` + no longer performs repository config I/O, JSON mutation, or uses the legacy + config-schema parser. + - Validate: facade tests prove missing/unreadable/invalid workflow config + returns an empty selection, target recording preserves unrelated fields, + `SetupTarget::All` records `opencode`, `claude`, and `pi`, and the source + inspection shows no config-schema/JSON persistence implementation remains + in `services::setup`. +- [x] AC8: `run_setup_for_mode` keeps its existing call-site sequence and + persists integration state only after asset installation succeeds; normal + setup and `ConfigLifecycle` continue using the compatibility facades without + composition-root wiring or orchestration migration. + - Validate: setup facade/orchestration tests and source inspection confirm the + order `load workflows -> install assets -> persist configuration -> render` + and that an installation failure leaves no newly recorded target. + +### Full validation + +- `nix run .#pkl-check-generated` +- `nix flake check` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` + +### Context sync + +- `context/architecture.md` — document the repository integration-configuration + vertical slice, its port/use-case/adapter ownership, and the continued + compatibility-facade boundary. +- `context/sce/setup-repo-local-config-bootstrap.md` — update implementation + ownership from `services::setup` JSON persistence to the application port and + filesystem adapter while retaining setup ordering and compatibility behavior. +- `context/glossary.md` — add the repository integration-configuration port, + adapter, and use-case terminology if the new boundary is part of current + architecture vocabulary. +- `context/context-map.md` — update annotations only if the ownership changes + make an existing setup/config context entry materially incomplete. + +## Constraints and non-goals + +- **In scope:** `IntegrationTarget::config_id`; `cli/src/application/ports/integration_config_repository.rs`; `cli/src/application/use_cases/{load_persisted_optional_workflows,record_integration_installation,ensure_repo_config}.rs`; `cli/src/adapters/outbound/filesystem/integration_config_repository.rs`; module registrations; the three public setup facades and their focused tests. +- **Out of scope:** moving `run_setup_for_mode`; setup rendering; `SetupCommand`; prompts; workflow validation; Git repository discovery; hooks; `ConfigLifecycle`; general/global configuration loading or precedence; JSON Schema validation; composition-root wiring; asset installation migration changes. +- **Constraints:** Preserve current public function signatures, call-site order, output/error context, canonical bootstrap payload, optional-workflow best-effort fallback, unknown integration target strings, target ordering, pretty serialization, and final-newline behavior. Use no new crate dependencies. Keep all JSON-document merge logic in the outbound adapter rather than introducing a strict whole-document domain struct. +- **Non-goal:** Generalizing this boundary into `ConfigStore` or making it responsible for global configuration, environment precedence, observability, database, or unrelated config domains. +- **Precondition:** The in-flight Nix CI and release-validation jobs for the preceding slice must finish before implementation begins. The stale Clippy-warning sentence in the completed `context/plans/install-integration-assets.md` validation record must be removed before the repository-config slice starts. + +## Assumptions + +- `IntegrationConfigRepository::record_installation` receives a concrete slice of `IntegrationTarget`; the use case owns expansion of `IntegrationTargetSelection::All`. +- The adapter continues to represent the document as `serde_json::Value`, preserving unrelated top-level fields and unknown string target IDs instead of round-tripping through a strict typed config model. +- `LoadPersistedOptionalWorkflows` delegates errors to its caller, while the legacy `persisted_optional_workflows` facade alone applies `unwrap_or_default()` for missing, unreadable, or invalid configuration. +- Existing adapter tests may use the repository's established filesystem-fixture style; all repository verification still runs through the Nix/Cargo wrapper boundary. + +## Task stack + +- [x] T01: `Clear the stale prior-slice validation warning before migration` (status:done) + - Task ID: T01 + - Goal: Remove the stale Clippy-warning follow-up sentence from the completed `install-integration-assets` plan after the preceding Nix CI and release-validation jobs have concluded, leaving its validated evidence internally consistent. + - Boundaries (in/out of scope): In — the single stale warning in `context/plans/install-integration-assets.md` and recording the completed CI/release outcomes in the task evidence. Out — changing completed implementation tasks, revalidating the new repository-config slice, or modifying application code. + - Dependencies: none + - Done when: The completed plan no longer claims that Clippy validation remains incomplete, and the prerequisite CI/release-validation outcomes are known before T02 begins. + - Verification notes (commands or checks): Inspect the relevant `Validation Report` and `Residual risks` text in `context/plans/install-integration-assets.md`; record the completed CI/release-validation status without changing historical task evidence. + - Evidence: Confirmed via the GitHub check-runs API for the `hexagonal` branch head commit (`8e34b20`) that all 8 checks — including `Nix CI (ubuntu-latest)`, `Nix CI (macos-latest)`, `Release validation (ubuntu-latest)`, and `Release validation (macos-latest)` — are `completed` with `conclusion: success`. Removed the stale sentence "Full validation remains incomplete until the Clippy diagnostics are repaired." from the `Residual risks` section of `context/plans/install-integration-assets.md`; that plan's own Validation Report already recorded a passing `nix flake check` (which includes Clippy) and an empty "Failed checks and follow-ups" section, so the sentence contradicted its own recorded evidence. No historical task evidence was changed. + - Verification: `git diff --check -- context/plans/install-integration-assets.md` — passed, no whitespace errors. Manually re-read the `Residual risks` section — no remaining reference to incomplete Clippy validation. + +- [x] T02: `Define the integration configuration port and concrete target identifiers` (status:done) + - Task ID: T02 + - Goal: Add `IntegrationTarget::config_id()` and the application-owned `IntegrationConfigRepository` trait, then register the new port module. + - Boundaries (in/out of scope): In — `cli/src/domain/integration/target.rs`, `cli/src/application/ports/integration_config_repository.rs`, and module registration. Out — use-case behavior, adapter I/O, facade changes, and `run_setup_for_mode`. + - Dependencies: T01 + - Done when: The port exposes `ensure_exists`, `load_optional_workflows`, and `record_installation` with the requested signatures; target IDs are canonical; and application/domain architecture checks pass. + - Verification notes (commands or checks): `nix develop -c ./scripts/check-cli-architecture.sh`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_id`. + - Evidence: Added `IntegrationTarget::config_id(self) -> &'static str` in `cli/src/domain/integration/target.rs`, returning `"opencode"`, `"claude"`, `"pi"` for the three concrete targets (mirrors the existing `services::setup::integration_target_id_str`, which T06 retires). Added `cli/src/application/ports/integration_config_repository.rs` defining `pub(crate) trait IntegrationConfigRepository` with `type Error;` and `ensure_exists(&self, repository_root: &Path) -> Result<(), Self::Error>`, `load_optional_workflows(&self, repository_root: &Path) -> Result, Self::Error>`, and `record_installation(&self, repository_root: &Path, targets: &[IntegrationTarget], optional_workflows: &[String]) -> Result<(), Self::Error>`. Registered `pub(crate) mod integration_config_repository;` in `cli/src/application/ports/mod.rs`. Added `config_id_maps_each_concrete_target` unit test. Both new items are temporarily `#[allow(dead_code)]`, consumed starting with T03/T04 (port) and T05 (`config_id`). + - Verification: `nix develop -c ./scripts/check-cli-architecture.sh` — passed, no forbidden dependencies in domain or application layers. `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_id` — passed, 1 test (`config_id_maps_each_concrete_target`). `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed clean (also fixed a `clippy::trivially_copy_pass_by_ref` finding by taking `self` by value). + +- [x] T03: `Add repository-config bootstrap and workflow-loading use cases` (status:done) + - Task ID: T03 + - Goal: Implement `EnsureRepoConfig` and `LoadPersistedOptionalWorkflows` as thin repository-delegating use cases with focused fake-repository tests. + - Boundaries (in/out of scope): In — the two use-case files, use-case module registration, delegation tests, and repository-error propagation for loading. Out — concrete filesystem behavior, record-installation expansion, and compatibility facade wiring. + - Dependencies: T02 + - Done when: `EnsureRepoConfig::execute` delegates to `ensure_exists`, the loader returns the repository's `Vec` unchanged, and both use cases preserve repository errors without depending on `services` or infrastructure APIs. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml load_persisted_optional_workflows`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml ensure_repo_config`; `nix develop -c ./scripts/check-cli-architecture.sh`. + - Evidence: Added `cli/src/application/use_cases/ensure_repo_config.rs` with `EnsureRepoConfigRequest { repository_root: PathBuf }` and `EnsureRepoConfig::execute` delegating to `repository.ensure_exists(&request.repository_root)`. Added `cli/src/application/use_cases/load_persisted_optional_workflows.rs` with `LoadPersistedOptionalWorkflowsRequest { repository_root: PathBuf }` and `LoadPersistedOptionalWorkflows::execute` returning `repository.load_optional_workflows(&request.repository_root)` unchanged, including its `Result, R::Error>` errors. Registered both modules in `cli/src/application/use_cases/mod.rs`. Both use cases depend only on `crate::application::ports::integration_config_repository` and `std::path::PathBuf`, with no `services` or infrastructure dependency. Each file has focused fake-repository tests proving delegation and error propagation (`execute_delegates_to_ensure_exists_with_the_resolved_root`, `execute_propagates_repository_errors`, `execute_returns_the_repositorys_workflows_unchanged`, `execute_propagates_repository_errors_unchanged`). Both new types are temporarily `#[allow(dead_code)]`, consumed starting with the compatibility facades (T06), matching the existing T02 convention. + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml load_persisted_optional_workflows` — passed, 2 tests. `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml ensure_repo_config` — passed, 2 tests. `nix develop -c ./scripts/check-cli-architecture.sh` — passed, no forbidden dependencies in domain or application layers. `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed clean (fixed two `clippy::needless_pass_by_value` findings by taking `request` by reference). + +- [x] T04: `Add the record-installation use case with All expansion` (status:done) + - Task ID: T04 + - Goal: Implement `RecordIntegrationInstallation` and its request type so target selections are expanded before the repository port is called. + - Boundaries (in/out of scope): In — `record_integration_installation.rs`, request/use-case module registration, fake-repository tests for `All`, one target, workflow forwarding, and error propagation. Out — JSON mutation, filesystem access, facade wiring, and orchestration migration. + - Dependencies: T02 + - Done when: `All` invokes the repository once with concrete targets in `OpenCode`, `Claude`, `Pi` order; a single target invokes one concrete target; the optional-workflow slice is forwarded unchanged; and repository errors are returned. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml record_integration_installation`; `nix develop -c ./scripts/check-cli-architecture.sh`. + - Evidence: Added `cli/src/application/use_cases/record_integration_installation.rs` with `RecordIntegrationInstallationRequest { repository_root: PathBuf, selection: IntegrationTargetSelection, optional_workflows: Vec }` and `RecordIntegrationInstallation::execute`, which calls `request.selection.targets()` (the existing `IntegrationTargetSelection::targets()` expansion) and forwards the concrete slice plus `optional_workflows` unchanged to a single `repository.record_installation` call, returning `R::Error` unchanged. Registered `pub(crate) mod record_integration_installation;` in `cli/src/application/use_cases/mod.rs`. The use case depends only on `crate::application::ports::integration_config_repository` and `crate::domain::integration::IntegrationTargetSelection`, with no `services`, filesystem, or JSON dependency. Both new types are temporarily `#[allow(dead_code)]`, consumed starting with the compatibility facades (T06), matching the T02/T03 convention. Added focused fake-repository tests: `one_selection_records_a_single_concrete_target`, `all_selection_records_every_target_in_order` (asserts `OpenCode`, `Claude`, `Pi` order via one repository call), `execute_forwards_the_optional_workflow_slice_unchanged`, and `execute_propagates_repository_errors`. + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml record_integration_installation` — passed, 4 tests. `nix develop -c ./scripts/check-cli-architecture.sh` — passed, no forbidden dependencies in domain or application layers. `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed clean (fixed a `clippy::type_complexity` finding in the test fixture by introducing a `RecordInstallationCall` type alias). + +- [x] T05: `Implement the filesystem integration-configuration repository` (status:done) + - Task ID: T05 + - Goal: Move `.sce/config.json` lifecycle, JSON parsing/merge, compatibility-preserving target/workflow persistence, and pretty serialization into `FilesystemIntegrationConfigRepository`. + - Boundaries (in/out of scope): In — `cli/src/adapters/outbound/filesystem/integration_config_repository.rs`, filesystem module registration, canonical bootstrap payload, path resolution, adapter error context, and all required adapter tests. Out — use-case orchestration, setup facades, `ConfigLifecycle`, global config, schema validation, and strict typed document deserialization. + - Dependencies: T03, T04 + - Done when: Adapter tests prove missing bootstrap, non-overwrite, unrelated-field preservation, target order, append/deduplication, unknown target preservation, workflow replacement, invalid JSON/non-object errors, and exactly one final newline; all three port operations compile and use the existing error wording/context. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::integration_config_repository`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml integration_config_repository`. + - Evidence: Added `cli/src/adapters/outbound/filesystem/integration_config_repository.rs` implementing `IntegrationConfigRepository` for `FilesystemIntegrationConfigRepository` (`type Error = anyhow::Error`), porting the exact behavior and error wording previously inline in `services::setup::{bootstrap_repo_local_config, persisted_optional_workflows, persist_integration_targets}`: `ensure_exists` creates `.sce/config.json` with the canonical `$schema` bootstrap payload (reusing `services::agent_trace::SCE_WEB_BASE_URL` and `services::default_paths::RepoPaths`) only when missing, never overwriting an existing file; `load_optional_workflows` reads the document via `serde_json::Value` and returns `integrations.optional_workflows` as `Vec` (empty when absent), propagating read/parse/shape errors to the caller instead of defaulting (per the plan's `LoadPersistedOptionalWorkflows` assumption — only the legacy facade applies `unwrap_or_default()`); `record_installation` bootstraps a missing file first, then merges concrete `IntegrationTarget::config_id()` values into `integrations.target` (preserving existing order, unknown strings, and deduplicating), replaces `integrations.optional_workflows` with the current selection, and writes pretty JSON plus exactly one trailing newline. Registered `pub(crate) mod integration_config_repository;` in `cli/src/adapters/outbound/filesystem/mod.rs`. Kept all JSON-document merge logic on `serde_json::Value` in the adapter — no strict typed document struct — using no new crate dependencies (`serde_json` and `anyhow` were already CLI dependencies). Added 14 focused adapter tests covering: missing-config bootstrap and canonical payload, non-overwrite of an existing file, bootstrap-before-record on a missing file, unrelated top-level field preservation, existing target order preserved with new-target append/dedup, unknown existing target-string preservation, optional-workflow replacement, invalid-JSON and non-object top-level errors for both read and record paths, exactly-one-final-newline on write, recorded-workflow loading, empty-workflow default, and missing-file/invalid-JSON error propagation from `load_optional_workflows`. + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml filesystem::integration_config_repository` — passed, 14 tests. `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml integration_config_repository` — passed, the same 14 tests (no additional matches in the port module, which has no `#[cfg(test)]` tests of its own). `nix develop -c ./scripts/check-cli-architecture.sh` — passed, no forbidden dependencies in domain or application layers (the adapter layer is outside this check's scope and may depend on `services`, matching the existing `FilesystemIntegrationInstaller` precedent). `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed clean. + +- [x] T06: `Replace setup configuration persistence with compatibility facades` (status:done) + - Task ID: T06 + - Goal: Rewire `bootstrap_repo_local_config`, `persisted_optional_workflows`, and `persist_integration_targets` to the new use cases and adapter, remove legacy config-schema/JSON mutation and `integration_target_id_str`, and add facade/orchestration compatibility tests. + - Boundaries (in/out of scope): In — `cli/src/services/setup/mod.rs` facade bodies/import cleanup, setup tests proving best-effort workflow reads, target persistence/`All`, preserved unrelated fields, and persistence-after-install ordering. Out — changing `run_setup_for_mode` call sites or sequence, migrating setup rendering/prompts/hooks, changing `ConfigLifecycle`, and composition wiring. + - Dependencies: T04, T05 + - Done when: The three public functions retain their signatures and behavior; `run_setup_for_mode` still visibly performs workflow load, asset install, config persistence, then success rendering; `ConfigLifecycle` still calls `bootstrap_repo_local_config`; and required facade tests pass through the legacy entrypoints. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::`; source inspection for `integration_target_id_str`, `parse_file_config`, and JSON mutation removal from the setup config path; `nix develop -c ./scripts/check-cli-architecture.sh`. + - Evidence: Rewired all three public facades in `cli/src/services/setup/mod.rs` to construct and invoke the T02-T05 use cases against `FilesystemIntegrationConfigRepository`, preserving each function's existing signature: `bootstrap_repo_local_config` now delegates to `EnsureRepoConfig::execute`; `persisted_optional_workflows` delegates to `LoadPersistedOptionalWorkflows::execute` and applies `.unwrap_or_default()` (the only place that defaults) so a missing/unreadable/invalid config still yields an empty selection; `persist_integration_targets` delegates to `RecordIntegrationInstallation::execute`, converting `SetupTarget` to `IntegrationTargetSelection` via a new private `integration_target_selection_for` helper (also reused by `install_embedded_setup_assets`, replacing its duplicate inline match). Removed the inline `repo_local_config_bootstrap_payload` helper, the `integration_target_id_str` function and its unit test, and the `use serde_json::json;` import — no config-schema parsing, `serde_json` value mutation, or JSON I/O remains in production code in `cli/src/services/setup/mod.rs` (confirmed by inspection; the crate's only remaining `serde_json`/`parse_file_config` references in that file are in the new facade tests, which assert against written JSON). `run_setup_for_mode`'s call-site body and order (`persisted_optional_workflows` fallback, then `install_embedded_setup_assets`, then `persist_integration_targets`, then success rendering) and `ConfigLifecycle::setup`'s call to `bootstrap_repo_local_config` were left untouched. Removed the now-unconsumed `#[allow(dead_code)]` markers on `IntegrationConfigRepository`, `IntegrationTarget`/`IntegrationTargetSelection`/`config_id`/`targets`, and the three T03/T04 use cases and their request types, since T06 is their real consumer. Added 8 new facade/orchestration tests in `cli/src/services/setup/mod.rs`: `bootstrap_repo_local_config_facade_creates_a_missing_config`; `persisted_optional_workflows_facade_returns_the_recorded_selection`; `persisted_optional_workflows_facade_returns_an_empty_selection_when_config_is_missing`; `persisted_optional_workflows_facade_returns_an_empty_selection_when_config_is_invalid`; `persist_integration_targets_facade_preserves_unrelated_fields_and_records_all_targets` (also proves `SetupTarget::All` records `opencode`, `claude`, `pi`); `run_setup_for_mode_installs_then_persists_configuration_after_a_successful_install` (proves install-then-persist ordering and success rendering); `run_setup_for_mode_leaves_no_recorded_target_when_installation_fails` (a non-directory repository root fails the installer's writability preflight before any config write, proving no config file, and therefore no newly recorded target, is created on installation failure). + - Verification: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — passed, 23 tests. `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (full crate) — passed, 236 tests, 0 failed. `nix develop -c ./scripts/check-cli-architecture.sh` — passed, no forbidden dependencies in domain or application layers. `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed clean. Source inspection: `grep -n "parse_file_config\|serde_json\|integration_target_id_str" cli/src/services/setup/mod.rs` shows matches only inside the new `#[cfg(test)]` facade tests (asserting against JSON the adapter wrote), confirming no config-schema parsing or JSON mutation implementation remains in `services::setup` production code. + +- [x] T07: `Record the repository-config architecture and compatibility ownership` (status:done) + - Task ID: T07 + - Goal: Update current-state context to describe the landed repository integration-configuration port/use-case/adapter slice and its retained setup compatibility facade. + - Boundaries (in/out of scope): In — the context files listed under Context sync, updated only where code truth changed. Out — implementation changes, plan validation, unrelated config architecture, and cleanup beyond the requested stale prior-plan warning. + - Dependencies: T06 + - Done when: Architecture and setup-bootstrap context identify the filesystem adapter as the owner of repository config I/O/JSON merge and identify the three setup functions as compatibility facades; glossary/map changes are made only when needed for discoverability. + - Verification notes (commands or checks): Read the updated context claims against the final source layout; run `git diff --check`. + - Evidence: Updated `context/architecture.md`'s "CLI internal hexagonal architecture" section with a new "third landed slice" paragraph naming `IntegrationTarget::config_id`, the `IntegrationConfigRepository` port, the three use cases (`EnsureRepoConfig`, `LoadPersistedOptionalWorkflows`, `RecordIntegrationInstallation`), `FilesystemIntegrationConfigRepository`'s `serde_json::Value`-based merge/compatibility behavior, and the three `services::setup` compatibility facades, matching the corresponding `context/sce/setup-repo-local-config-bootstrap.md` cross-reference used by the prior two slices; also extended the existing `cli/src/services/setup/mod.rs` bullet to name the moved-out config responsibilities and their facades. Rewrote the "Implementation" section of `context/sce/setup-repo-local-config-bootstrap.md` to point at the vertical slice (port, three use cases, `FilesystemIntegrationConfigRepository`) instead of the retired inline JSON mutation, named `persisted_optional_workflows` as the sole error-defaulting facade, and moved the bootstrap-payload/JSON-merge ownership sentence to the adapter while keeping the context-baseline-bootstrap sentence (a separate, earlier-landed slice) intact. Added four `context/glossary.md` entries: `repository integration-configuration vertical slice`, `IntegrationConfigRepository`, `EnsureRepoConfig` / `LoadPersistedOptionalWorkflows` / `RecordIntegrationInstallation`, and `FilesystemIntegrationConfigRepository`. Verified `context/context-map.md`'s existing `setup-repo-local-config-bootstrap.md` entry describes external behavior (bootstrap, persistence, precedence) rather than implementation ownership, so it remains accurate and was left unedited, matching the plan's "update only if materially incomplete" instruction. Ran the mandatory root pass over `context/overview.md`, `context/architecture.md`, `context/glossary.md`, `context/patterns.md`, and `context/context-map.md`: `overview.md` and `patterns.md` contain no reference to this subsystem's functions or the hexagonal-slice vocabulary and are not contradicted by the completed implementation, so neither required an edit. + - Verification: `git diff --check -- context/architecture.md context/glossary.md context/sce/setup-repo-local-config-bootstrap.md` — passed, no whitespace errors. Manually re-read the edited context claims against `cli/src/adapters/outbound/filesystem/integration_config_repository.rs`, `cli/src/application/ports/integration_config_repository.rs`, `cli/src/application/use_cases/{ensure_repo_config,load_persisted_optional_workflows,record_integration_installation}.rs`, and the three facades (`bootstrap_repo_local_config`, `persisted_optional_workflows`, `persist_integration_targets`) in `cli/src/services/setup/mod.rs` — every claim (port method names/signatures, `serde_json::Value` document representation, error-defaulting confined to `persisted_optional_workflows`, facade-over-use-case wiring) matches the final source layout. + +## Open questions + +None. The port, use-case responsibilities, adapter compatibility semantics, +facade boundaries, test obligations, and explicit non-goals are specified. The +only operational prerequisite is completion of the already-running validation +jobs, followed by removal of the stale prior-plan warning before T02 begins. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-04 + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0 (71 files generated, inventory hash matched) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (236 passed, 0 failed) +- `nix develop -c ./scripts/check-cli-architecture.sh` -> exit 0 (no forbidden dependencies in domain or application layers; AC1) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml integration_config_repository` -> exit 0 (14 passed; AC2/AC3/AC4/AC5) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml record_integration_installation` -> exit 0 (4 passed; AC2/AC6) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_id` -> exit 0 (1 passed; AC2) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` -> exit 0 (23 passed; AC6/AC7/AC8) +- `grep -n "parse_file_config\|serde_json\|integration_target_id_str" cli/src/services/setup/mod.rs` (source inspection) -> matches only inside the `mod tests` block starting at line 1350 (AC7) +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml` -> exit 0 (repair: applied rustfmt to the 5 files flagged by the prior `failed` run) +- `nix flake check` (retry) -> exit 0 (`all checks passed!` — `cli-architecture`, `cli-clippy`, `cli-fmt`, `cli-tests` all green) +- `nix run .#pkl-check-generated` (retry) -> exit 0 (71 files, same inventory hash) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (retry) -> exit 0 (236 passed, 0 failed) + +### Scaffolding removed + +- None. + +### Success-criteria verification + +- [x] AC1: `nix develop -c ./scripts/check-cli-architecture.sh` passed with no forbidden dependencies; the `cli-architecture` flake attribute also passed as part of the fully green `nix flake check` retry. +- [x] AC2: `config_id` test (1 passed) confirms `opencode`/`claude`/`pi`; `record_integration_installation` tests (4 passed) confirm `All` expansion in `OpenCode`, `Claude`, `Pi` order via a single repository call. +- [x] AC3: `integration_config_repository` adapter tests confirm bootstrap-on-missing and non-overwrite-on-existing behavior. +- [x] AC4: `integration_config_repository` adapter tests confirm unrelated-field preservation, target order, append/dedup, unknown-string preservation, and workflow replacement. +- [x] AC5: `integration_config_repository` adapter tests confirm stable invalid-JSON/non-object errors and the exactly-one-final-newline write contract. +- [x] AC6: `record_integration_installation`, `ensure_repo_config`/`load_persisted_optional_workflows` (covered by the full 236-test run), and `setup::` facade tests confirm delegation, `All` expansion, unchanged workflow forwarding, and error propagation. +- [x] AC7: `setup::` facade tests (23 passed) confirm facade behavior; source inspection confirms no `serde_json`/config-schema-parsing production code remains in `cli/src/services/setup/mod.rs` outside its test module. +- [x] AC8: `run_setup_for_mode_installs_then_persists_configuration_after_a_successful_install` and `run_setup_for_mode_leaves_no_recorded_target_when_installation_fails` (both in the `setup::` run) confirm the load/install/persist/render order and that a failed install leaves no recorded target. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- The five new/edited source files were untracked at the start of this validation run (`git status` showed `??` for the four new files); `nix flake check` builds from the git-tracked tree, so they were staged with `git add` (not committed) before running the check. No file content was changed by staging. The subsequent `cargo fmt` repair was also staged the same way for the retry. diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index 2759825e..87817858 100644 --- a/context/sce/setup-repo-local-config-bootstrap.md +++ b/context/sce/setup-repo-local-config-bootstrap.md @@ -47,11 +47,10 @@ The same write also records the run's resolved optional-workflow selection under ## Implementation -- `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>`, `bootstrap_context_baseline(repository_root: &Path) -> Result`, and `persist_integration_targets(repository_root: &Path, target: SetupTarget, selected_optional_workflows: &[String]) -> Result<()>`, which writes both `integrations.target` and `integrations.optional_workflows`. `run_setup_for_mode` resolves the selection (the selection handed to it, else the persisted value read through the exported `persisted_optional_workflows`, which parses the repo-local file via `parse_file_config`) before installing and persisting it. `cli/src/services/setup/command.rs` resolves the repository root before any prompt so it can seed the interactive prompt from that persisted value, and passes the prompted selection — when the run was interactive — to `run_setup_for_mode` ahead of the request's `--workflow` list. +- `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>`, `bootstrap_context_baseline(repository_root: &Path) -> Result`, `persisted_optional_workflows(repository_root: &Path) -> Vec`, and `persist_integration_targets(repository_root: &Path, target: SetupTarget, selected_optional_workflows: &[String]) -> Result<()>`. These three config functions are compatibility facades over the repository integration-configuration vertical slice (see `context/architecture.md`'s "CLI internal hexagonal architecture"): each constructs a `FilesystemIntegrationConfigRepository` outbound adapter and runs the matching application use case — `EnsureRepoConfig`, `LoadPersistedOptionalWorkflows`, and `RecordIntegrationInstallation` respectively (`cli/src/application/use_cases/{ensure_repo_config,load_persisted_optional_workflows,record_integration_installation}.rs`) — against the `IntegrationConfigRepository` port (`cli/src/application/ports/integration_config_repository.rs`). `persisted_optional_workflows` is the sole place that defaults a repository error (missing file, unreadable file, invalid JSON, or a non-object top-level value) to an empty selection; the use case and adapter otherwise propagate errors unchanged. `run_setup_for_mode` resolves the selection (the selection handed to it, else the persisted value read through `persisted_optional_workflows`) before installing and persisting it. `cli/src/services/setup/command.rs` resolves the repository root before any prompt so it can seed the interactive prompt from that persisted value, and passes the prompted selection — when the run was interactive — to `run_setup_for_mode` ahead of the request's `--workflow` list. - `cli/src/services/local_db/lifecycle.rs` implements `LocalDbLifecycle::setup()` for local DB initialization. - `cli/src/services/agent_trace_db/lifecycle.rs` implements `AgentTraceDbLifecycle::setup()` for Agent Trace DB initialization. -- Repo-local config bootstrap uses `RepoPaths::sce_config_file()` and `RepoPaths::sce_dir()`. Context baseline bootstrap is implemented through the CLI's internal hexagonal layers (see `context/architecture.md`'s "CLI internal hexagonal architecture"): `bootstrap_context_baseline` is a thin compatibility facade that constructs a `FilesystemContextStore` outbound adapter (`cli/src/adapters/outbound/filesystem/context_store.rs`), runs the `EnsureContextBaseline` use case (`cli/src/application/use_cases/ensure_context_baseline.rs`) against the domain `ContextBaseline::sce_default()` manifest (`cli/src/domain/context/baseline.rs`), and renders the report through `render_context_baseline_report` (`cli/src/adapters/inbound/cli/setup.rs`), which returns the unchanged `Context baseline ensured.` success text. `RepoPaths` no longer has `context_*` accessors; the adapter builds baseline paths by joining `repository_root` with `ContextBaseline`'s own relative paths. -- The canonical payload constant is `REPO_LOCAL_CONFIG_BOOTSTRAP_PAYLOAD`. +- Repo-local config bootstrap, JSON read/merge, and pretty-serialization-with-final-newline behavior live in `cli/src/adapters/outbound/filesystem/integration_config_repository.rs`'s `FilesystemIntegrationConfigRepository`, which owns `RepoPaths::sce_config_file()`/`RepoPaths::sce_dir()` resolution and the canonical bootstrap payload (a private `repo_local_config_bootstrap_payload()` helper in that file); the document stays a `serde_json::Value` rather than a strict typed struct, so unrelated top-level fields and unknown existing `integrations.target` strings survive untouched. Context baseline bootstrap is a separate, earlier-landed slice through the same internal hexagonal layers: `bootstrap_context_baseline` is a thin compatibility facade that constructs a `FilesystemContextStore` outbound adapter (`cli/src/adapters/outbound/filesystem/context_store.rs`), runs the `EnsureContextBaseline` use case (`cli/src/application/use_cases/ensure_context_baseline.rs`) against the domain `ContextBaseline::sce_default()` manifest (`cli/src/domain/context/baseline.rs`), and renders the report through `render_context_baseline_report` (`cli/src/adapters/inbound/cli/setup.rs`), which returns the unchanged `Context baseline ensured.` success text. `RepoPaths` no longer has `context_*` accessors; the context-store adapter builds baseline paths by joining `repository_root` with `ContextBaseline`'s own relative paths. - `cli/src/services/setup/command.rs` runs `bootstrap_context_baseline` immediately after `ensure_git_repository`. Context-only requests return there. Normal modes then derive a repo-root-scoped `AppContext` and aggregate lifecycle providers in config → local_db → auth_db → agent_trace_db → hooks order; `ConfigLifecycle::setup()` calls `bootstrap_repo_local_config(...)`, `LocalDbLifecycle::setup()` initializes the local DB, `AuthDbLifecycle::setup()` initializes the auth DB, and `AgentTraceDbLifecycle::setup()` initializes the Agent Trace DB. ## Relationship to other setup contracts