diff --git a/CHANGELOG.md b/CHANGELOG.md index f063d3f..917042a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ Between releases: see `git log` for merged work and [`ROADMAP.md`](ROADMAP.md) f ### Added +- **`shardmind update` command** — the feature the three-way merge engine was built to serve. Full pipeline: fetch new shard → apply schema migrations → prompt only for newly required values → offer newly optional modules → decide per-file fate for modified files the new shard no longer ships → three-way merge every modified file in parallel → resolve conflicts via `DiffView` → snapshot-and-write → post-update hook → summary. Any failure between snapshot and state-write walks the snapshot back; the vault is indistinguishable from pre-update. + - `source/core/migrator.ts` — `applyMigrations(values, current, target, migrations)` transforms `shard-values.yaml` across versions. Filter rule is `currentVersion < from_version ≤ targetVersion` (idempotent: re-running an upgrade at the same target version picks up nothing). Four change types (`rename` / `added` / `removed` / `type_changed`); `rename` refuses to clobber an existing target value — warns and keeps both keys instead of destroying user data. + - `source/core/update-planner.ts` — pure planner. Inputs grouped as `{ vault, values, newShard, removedFileDecisions }` so call sites can't accidentally mix fields from two different shards. Emits `UpdatePlan` with 10 action variants (`overwrite / auto_merge / conflict / noop / skip_volatile / add / restore_missing / delete / keep_as_user`) plus a `pendingConflicts` queue for the state machine to drive. Per-modified-file merges run in parallel via `mapConcurrent(drift.modified, 16, …)`. `theirsHash` is captured at plan time and threaded through to the executor so `keep_mine`/`skip` resolutions don't need to re-read + re-hash the file. + - `source/core/update-executor.ts` — applies the plan with snapshot-based rollback. Backup directory allocation is collision-safe under same-millisecond concurrent updates (ISO timestamp retains ms, then a numeric suffix is probed if EEXIST). Snapshot copies the plan's touched files plus `.shardmind/state.json`, cached manifest + schema + templates in parallel (`SNAPSHOT_CONCURRENCY=16`). Write pass runs before delete pass so a rename-style move (delete + add at a different path) can't clobber the incoming file. + - `source/core/values-io.ts` — one YAML reader for both install's `--values` prefill and update's canonical `shard-values.yaml` load. Install filters unknown keys against the schema; update keeps everything so migrations can handle the shape change. Same read → parse → type-check code path. + - `source/commands/update.tsx` + `source/commands/hooks/use-update-machine.ts` — state machine with phases `booting → loading → (no-install | up-to-date | prompt-new-values → prompt-new-modules → prompt-removed-files → resolving-conflicts) → writing → summary`. Mirrors the `useInstallMachine` pattern. + - `source/commands/hooks/shared.ts` — `summarizeHook` and `useSigintRollback` shared across install + update. SIGINT always runs the tempdir cleanup (plugging a leak where cancelling during the prompt/wizard phase left the extracted shard on disk). + - 6 new Ink components: `DiffView` (three-way conflict with ±3 context lines, color-coded yours/shard, CRLF-tolerant), `NewValuesPrompt`, `NewModulesReview`, `RemovedFilesReview`, `UpdateSummary`, plus `CommandFrame` (dry-run banner + keyboard legend, shared with install) and `CommandProgress` (renamed from `InstallProgress`, now used by both commands). + - Six new error codes typed in `source/runtime/errors.ts`: `UPDATE_NO_INSTALL`, `UPDATE_SOURCE_MISMATCH`, `UPDATE_CACHE_MISSING`, `UPDATE_WRITE_FAILED`, `MIGRATION_INVALID_VERSION`, `MIGRATION_TRANSFORM_FAILED`. + - Flags: `--yes` (auto-accept every prompt; auto-keep conflicts; include every new optional module), `--verbose` (per-file action history during write), `--dry-run` (plan + summarize without touching the vault or allocating a backup). +- **Adversarial test harness for the update stack** — matches the merge engine's bar. 18 migrator adversarial tests (prototype-key pollution, non-Error throws, unserializable transforms, BOM + null bytes, cyclic value graphs, pre-release + build-metadata semver, chained renames) plus 4 `fast-check` property tests × 200 runs = 800 generative scenarios. 11 update-planner + executor adversarial tests (missing cached template → `conflictFromDirect` fallback, iterator array shrink cleanup, CRLF on user files, Unicode + emoji filenames, inconsistent state + drift inputs, concurrent `createBackupDir` calls, `rollbackUpdate` idempotency, render determinism property). All 62 new tests brought the suite from 341 to 403 passing. +- **6 component tests** for the new Ink components (`DiffView`, `CommandFrame`, `NewValuesPrompt`, `NewModulesReview`, `RemovedFilesReview`, `UpdateSummary`) using `ink-testing-library`. Component tests caught a double-submit bug in `RemovedFilesReview` and `NewValuesPrompt` where a re-entrant render would fire `onComplete` twice; both components now guard via a `submittedRef` + per-file `Select` remount. - **Three-way merge engine** (`source/core/drift.ts`, `source/core/differ.ts`). Solves "propagate template updates to existing vaults" — the same problem Backstage (Spotify) has had open for 3+ years and that cruft/create-react-app/Yeoman never solved. Uses `node-diff3`'s Khanna–Myers algorithm via `diff3MergeRegions` for accurate stats. - `detectDrift()` classifies every tracked file into `managed / modified / volatile / missing / orphaned` by sha256-comparing disk content against `state.json.rendered_hash`. Orphan detection is non-recursive: parent directories of tracked files become tracked dirs; files in a tracked dir not in `state.files` are orphans. Engine scaffolding (`shard-values.yaml`, `.shardmind/`) and third-party metadata (`.git/`, `.obsidian/`) are excluded. - `computeMergeAction()` returns `skip | overwrite | auto_merge | conflict` — skip when `base === ours`, overwrite for managed files, three-way merge for modified files. Conflict markers use git vocabulary: `<<<<<<< yours` / `=======` / `>>>>>>> shard update`. @@ -22,15 +34,31 @@ Between releases: see `git log` for merged work and [`ROADMAP.md`](ROADMAP.md) f ### Changed +- `InstallProgress` renamed to `CommandProgress`; both install and update commands render it. Callers should import from `source/components/CommandProgress.js`. +- `mapConcurrent` moved from `source/core/drift.ts` to `source/core/fs-utils.ts` so planner and executor can share the bounded-concurrency primitive. +- `CommandFrame` extracted — was `RootFrame` in `install.tsx` and `Frame` in `update.tsx`. Single component owns the dry-run banner + keyboard legend. - `ShardMindError.code` typed as `ErrorCode` instead of free-form `string` (compile-time check of every call site). - `detectDrift` now runs per-file reads in parallel via `Promise.all`, and runs the orphan scan in parallel with the classification. - `source/runtime/errno.ts` centralizes `errnoCode` / `isEnoent`; 8 copies of the `err instanceof Error && 'code' in err ? ...` pattern collapsed. ### Fixed +- `rename` migration no longer silently overwrites an existing value at the target key. If both the old and new keys have values, the migrator warns and keeps both (the user picks which to drop manually). Previously this was a silent data-loss bug found by the adversarial audit. +- `createBackupDir` allocates distinct backup directories even when called twice in the same millisecond. Previous timestamp stripped the fractional seconds, so two near-simultaneous updates could share a backup dir and clobber each other's rollback snapshots. +- `keep_as_user` decisions now untrack the file from `state.files` on apply. The file stays on disk (as the user asked), but the engine no longer considers it managed on future updates — matches the UI's "Keep my edits (untrack)" label. +- `DiffView` splits on `/\r?\n/` everywhere to match `differ.ts`'s canonical line splitter. Previously, CRLF content left `\r` in the output strings and Ink's renderer treated them as cursor-moving carriage returns, corrupting the terminal display. +- `conflictFromDirect` now receives the new-shard tempdir and produces a cache-relative `templateKey` instead of an absolute path. The fallback fires only on corrupt / missing cached templates, but when it did fire the state it wrote couldn't be re-used by subsequent updates. +- SIGINT now runs the shard tempdir cleanup regardless of which phase was active. Previously, cancelling with Ctrl-C during the wizard/prompt phases left the extracted tarball in the OS temp directory until the next reboot. - CRLF on Windows-saved user files no longer produces spurious conflicts against LF-rendered base/ours. ### Docs +- `docs/IMPLEMENTATION.md` §3 — Update data flow diagram rewritten to match the actual `useUpdateMachine` phase progression, including the three discrete prompt phases (new values, new modules, removed files) and the explicit rollback branch. +- `docs/IMPLEMENTATION.md` §4.10 — corrected migration filter rule (`currentVersion < from_version ≤ targetVersion`), added the "rename refuses to clobber existing target" invariant, added the `MIGRATION_INVALID_VERSION` throw. +- `docs/IMPLEMENTATION.md` §4.11, §4.12, §4.13 — new module specs for `update-planner.ts`, `update-executor.ts`, `values-io.ts` matching the §4.x style of the existing engine modules. +- `docs/IMPLEMENTATION.md` §6.5 — DiffView props updated to reflect `index` / `total` / `result` (not `mergeResult`), the `DiffAction` union, CRLF handling, and the disabled-editor placeholder (full integration tracked in issue #50). +- `docs/IMPLEMENTATION.md` §7 — error handling table adds the six new update + migration error codes. +- `docs/ARCHITECTURE.md` §10.5 — update flow expanded to cover the new flags (`--yes`, `--verbose`, `--dry-run`), the removed-files decision prompt, and the snapshot/rollback guarantee. References `§4.11` / `§4.12` in IMPLEMENTATION for detailed algorithms. +- `CLAUDE.md` — module table includes `update-planner.ts` / `update-executor.ts` / `values-io.ts`; source tree diagram updated with new components and the `commands/hooks/` directory. - `docs/ARCHITECTURE.md` §17 updated: scenario table now shows 20 rows, `§17.4` code sample shows the actual `diff3MergeRegions` algorithm, `§17.5` corrects the frontmatter-merge decision to match implementation (line-merge of rendered YAML, not YAML object deep-merge). - `docs/IMPLEMENTATION.md` §4.8 documents orphan detection semantics; §4.9 documents the `diff3MergeRegions` variant and CRLF split. diff --git a/CLAUDE.md b/CLAUDE.md index 528a49d..85edfba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,13 +57,23 @@ shardmind/ │ ├── commands/ │ │ ├── index.tsx # Status display (root command) │ │ ├── install.tsx # shardmind install -│ │ └── update.tsx # shardmind update +│ │ ├── update.tsx # shardmind update +│ │ └── hooks/ # State-machine + shared command hooks +│ │ ├── use-install-machine.ts +│ │ ├── use-update-machine.ts +│ │ └── shared.ts # summarizeHook, useSigintRollback │ ├── components/ -│ │ ├── StatusView.tsx # Quick status -│ │ ├── VerboseView.tsx # Detailed diagnostics (--verbose) +│ │ ├── CommandFrame.tsx # Dry-run banner + keyboard legend +│ │ ├── CommandProgress.tsx # Shared progress UI (install + update) +│ │ ├── StatusView.tsx # Quick status (planned) +│ │ ├── VerboseView.tsx # Detailed diagnostics (planned) │ │ ├── InstallWizard.tsx # Values prompts + module review │ │ ├── ModuleReview.tsx # Multiselect for modules -│ │ ├── DiffView.tsx # Three-way diff display +│ │ ├── DiffView.tsx # Three-way diff + conflict resolution +│ │ ├── NewValuesPrompt.tsx # Update: prompt for newly required values +│ │ ├── NewModulesReview.tsx # Update: offer newly optional modules +│ │ ├── RemovedFilesReview.tsx # Update: per-file keep/delete decision +│ │ ├── UpdateSummary.tsx # Final update report │ │ └── Header.tsx # Branded header │ ├── core/ │ │ ├── manifest.ts # Parse + validate shard.yaml @@ -75,7 +85,14 @@ shardmind/ │ │ ├── drift.ts # Ownership detection + drift analysis │ │ ├── differ.ts # Three-way merge (node-diff3) │ │ ├── migrator.ts # Apply schema migrations to values -│ │ └── modules.ts # Module resolution + file gating +│ │ ├── modules.ts # Module resolution + file gating +│ │ ├── update-planner.ts # Pure update plan from drift + new shard +│ │ ├── update-executor.ts # Apply update plan with rollback +│ │ ├── install-planner.ts # Pure install plan + collisions +│ │ ├── install-executor.ts # Apply install plan with rollback +│ │ ├── values-io.ts # Shared YAML load for shard-values.yaml +│ │ ├── hook.ts # Post-install / post-update hook lookup +│ │ └── fs-utils.ts # sha256, pathExists, toPosix, mapConcurrent │ ├── runtime/ # Exported for hook scripts │ │ ├── index.ts # Re-exports │ │ ├── values.ts # loadValues() @@ -178,6 +195,9 @@ Each file in `source/core/` maps 1:1 to a section in `docs/IMPLEMENTATION.md`: | `drift.ts` | §4.8 | Ownership detection + drift analysis | | `differ.ts` | §4.9 | Three-way merge via node-diff3 | | `migrator.ts` | §4.10 | Apply schema migrations to values | +| `update-planner.ts` | §4.11 | Plan update actions from drift + new-shard render | +| `update-executor.ts` | §4.12 | Apply update plan with snapshot-based rollback | +| `values-io.ts` | §4.13 | Shared YAML load for shard-values.yaml (install + update) | Read the spec section before implementing. It has inputs, outputs, algorithm steps, error cases, and test expectations. diff --git a/ROADMAP.md b/ROADMAP.md index c15f70f..d92e433 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,9 +39,9 @@ Ship the core: install, update, status. Prove that vault template upgrades work ### Milestone 4: Update Command + Status (Day 4) -- [ ] `commands/update.tsx` — upgrade flow with drift detection + DiffView ([#12](https://github.com/breferrari/shardmind/issues/12)) +- [x] `commands/update.tsx` — upgrade flow with drift detection + DiffView ([#12](https://github.com/breferrari/shardmind/issues/12)) - [ ] `commands/index.tsx` — status display + --verbose diagnostics ([#13](https://github.com/breferrari/shardmind/issues/13)) -- [ ] Integration test: install → modify files → update → verify merge behavior +- [x] Integration test: install → modify files → update → verify merge behavior - [ ] E2E test: all 3 commands via CLI invocation ### Milestone 5: Flagship Shard (Day 5) @@ -112,6 +112,8 @@ Deferred items surfaced during the v0.1 polish-pass architecture audit. None are - [ ] Encode state-schema migration rules (uses v0.1 framework) ([#40](https://github.com/breferrari/shardmind/issues/40)) - [ ] Re-evaluate `@inkjs/ui` dependency (upstream frozen; shim at `source/components/ui.ts` keeps swap cheap) ([#43](https://github.com/breferrari/shardmind/issues/43)) - [ ] Drop `LineInterner` workaround once `node-diff3` releases the prototype-lookup fix ([#49](https://github.com/breferrari/shardmind/issues/49), upstream [bhousel/node-diff3#87](https://github.com/bhousel/node-diff3/pull/87)) +- [ ] `$EDITOR` integration for DiffView conflict resolution ([#50](https://github.com/breferrari/shardmind/issues/50)) +- [ ] 24h update-check cache shared between status + update ([#51](https://github.com/breferrari/shardmind/issues/51)) --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4cec4cb..2272d32 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -702,31 +702,43 @@ shardmind update breferrari/obsidian-mind v3.5.0 → v4.0.0 - Fetching v4.0.0... + Fetching v4.0.0… ``` -If new values were added to the schema: prompt only for those. If schema migrations exist: apply automatically, show summary. If new modules were added: offer to include them. +If new values were added to the schema: prompt only for those. If schema migrations exist: apply automatically, surface warnings in the final summary. If new optional modules appeared in the new shard: offer to include them (default on). If any modified-by-user files are no longer produced by the new shard: ask whether to keep your version (untracked) or delete. Then the diff review: ``` 43 files unchanged (silent re-render) - 2 files updated (no conflict) + 2 files auto-merged 1 file needs your review: - CLAUDE.md - ┌─ yours ──────────────────────────────┐ - │ ## Custom Section │ - │ My custom workflow for auth reviews │ - ├─ shard update ───────────────────────┤ - │ ## Auth Review Workflow │ - │ Updated process for Q2 2026 │ - └──────────────────────────────────────┘ - - [Accept new] [Keep mine] [Open in editor] [Skip] + Conflict in CLAUDE.md (1 of 1) + lines 47–52 + before line + <<<<<<< yours + ## Custom Section + My custom workflow for auth reviews + ======= + ## Auth Review Workflow + Updated process for Q2 2026 + >>>>>>> shard update + after line + + 150 unchanged · 8 auto-merged · 1 region conflicted + + [Accept new] [Keep mine] [Skip] (Open in editor · v0.2) ``` -After resolution: update state.json → update cached templates → post-update hook → summary. +After resolution: the executor snapshots every path it will touch to `.shardmind/backups/update-/`, applies writes and deletes in two passes (writes first so a delete can't clobber a new file at the same path), re-caches the manifest + schema + templates, writes new `state.json`, then runs the non-fatal post-update hook. Any failure between snapshot and state-write walks the snapshot back and leaves the vault indistinguishable from pre-update. + +Flags: +- `--yes` — skip every prompt; opt into every new optional module and auto-keep every conflict (useful for unattended CI upgrades). +- `--verbose` — show per-file action history during the write phase. +- `--dry-run` — run the full pipeline (fetch, migrate, plan, merge) without touching the vault; the summary reports what *would* happen. + +Implementation modules: `source/core/migrator.ts` (IMPLEMENTATION §4.10), `source/core/update-planner.ts` (§4.11), `source/core/update-executor.ts` (§4.12). Orchestration lives in `source/commands/hooks/use-update-machine.ts`. The full phase diagram is in IMPLEMENTATION §3. ### 10.6 Install Location diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 77f9292..0794c07 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -96,40 +96,52 @@ graph TD ## 3. Data Flow: Update +Concretely driven by the `useUpdateMachine` hook in `source/commands/hooks/use-update-machine.ts`. Each node below corresponds to a phase variant in the machine's `Phase` union. + ```mermaid graph TD A["shardmind update"] --> B - B["state.ts
Read state.json → source, version, modules, files"] --> C - C["registry + download
Check GitHub tags → compare semver
If no newer version → exit
If newer → download tarball"] --> D - D["schema.ts
Parse new shard-schema.yaml
Diff against cached schema"] --> E - E["migrator.ts
Apply migrations (renames, additions, removals)
Report changes to user"] --> F - F["Prompt — Ink TUI
New values only + new modules offered"] --> G + B["state.ts
Read state.json → source, version, modules, files
Absent → no-install phase, exit"] --> C + C["registry + download
Resolve state.source → tarball
version + tarball_sha match state → up-to-date, exit"] --> D + D["manifest + schema
Parse new shard.yaml, shard-schema.yaml"] --> E + E["values-io + migrator
Load shard-values.yaml → applyMigrations
(rename/added/removed/type_changed)"] --> F + + F["computeSchemaAdditions
newRequiredKeys[], newOptionalModules[]"] --> G1 + G1{New required
values?} + G1 -->|Yes| G2["prompt-new-values
NewValuesPrompt — Ink TUI"] + G1 -->|No| H1 + G2 --> H1 + H1{New optional
modules?} + H1 -->|Yes| H2["prompt-new-modules
NewModulesReview — Ink TUI"] + H1 -->|No| I + H2 --> I + + I["detectDrift + renderNewShard (parallel)
Classify files; render new shard once"] --> J1 - G["drift.ts
For each file: sha256(disk) vs state hash
Classify: managed | modified | volatile | missing"] --> H + J1{Modified files
no longer in new shard?} + J1 -->|Yes| J2["prompt-removed-files
RemovedFilesReview — Ink TUI
keep / delete per file"] + J1 -->|No| K + J2 --> K - H{Ownership?} - H -->|MANAGED| I["old_rendered == new_rendered?
Yes → skip
No → silent overwrite"] - H -->|MODIFIED| J["Three-way merge via node-diff3
base: cached template + old values
theirs: user's file on disk
ours: new template + new values"] - H -->|VOLATILE| K["Skip. Update hash in state
for future reference."] + K["planUpdate
Per-file UpdateAction + pendingConflicts
(modified-file merges run in parallel, bounded 16)"] --> L1 - J --> L{Conflicts?} - L -->|No| M["Auto-merge. Apply silently."] - L -->|Yes| N["DiffView — Ink TUI
Accept new | Keep mine | Editor | Skip"] + L1{Pending
conflicts?} + L1 -->|Yes| L2["resolving-conflicts loop
DiffView per file
accept_new / keep_mine / skip"] + L1 -->|No| M + L2 --> M - I --> O - M --> O - N --> O - K --> O + M["writing — update-executor
Snapshot → write pass → delete pass
Re-cache templates + manifest + schema
writeState"] --> N + N["post-update hook
(non-fatal, deferred runtime per #30)"] --> O + O["summary — UpdateSummary
Counts, conflict resolutions,
migration warnings, hook output"] - O["state.ts
Update state.json + cached templates"] --> P - P["hooks
Run post-update.ts (non-fatal)"] --> Q - Q["Summary — Ink
Updated 3.5.0 → 4.0.0
43 silent · 2 merged · 1 skipped"] + M -->|Any failure| R["rollbackUpdate
Restore snapshot + erase added paths"] style A fill:#e94560,stroke:#e94560,color:#fff - style H fill:#f5a623,stroke:#f5a623,color:#000 - style N fill:#e94560,stroke:#e94560,color:#fff - style Q fill:#0f9d58,stroke:#0f9d58,color:#fff + style L2 fill:#e94560,stroke:#e94560,color:#fff + style M fill:#f5a623,stroke:#f5a623,color:#000 + style O fill:#0f9d58,stroke:#0f9d58,color:#fff + style R fill:#777,stroke:#777,color:#fff ``` --- @@ -584,8 +596,8 @@ Shard update version of conflicting lines **Inputs**: ```typescript -migrate( - currentValues: Record, +applyMigrations( + values: Record, currentVersion: string, targetVersion: string, migrations: Migration[], @@ -599,23 +611,118 @@ interface MigrationResult { ``` **Algorithm**: -1. Filter migrations where `semver.gt(targetVersion, migration.from_version)` and `semver.gte(migration.from_version, currentVersion)` -2. Sort by `from_version` ascending -3. For each migration, for each change: - - `rename`: copy `values[old]` to `values[new]`, delete `values[old]` - - `added`: if key doesn't exist, set `values[key] = default` - - `removed`: delete `values[key]`, add warning - - `type_changed`: apply transform expression (simple JS eval with value as input) -4. Return transformed values + changelog +1. Filter migrations where `semver.gt(migration.from_version, currentVersion)` and `semver.lte(migration.from_version, targetVersion)` — i.e. `currentVersion < from_version ≤ targetVersion`. This makes migrations idempotent: re-running an upgrade at the same target version picks up nothing. +2. Sort by `from_version` ascending. +3. For each migration in order, for each change: + - `rename`: if `values[old]` present and `values[new]` absent, copy and delete the old key. If the target is already occupied, warn + skip (never overwrite — that would destroy user data). If the source is missing, warn + skip. + - `added`: if `values[key]` is absent, set to `default`. If already present, no-op (no warning). + - `removed`: delete `values[key]` and warn (users deserve to know a key they set is being discarded). No-op when already absent. + - `type_changed`: evaluate `transform` in a `new Function('value', 'return ()')` sandbox. Catch and warn on any throw, preserving the original value. Sandboxing untrusted shards is not a goal of this layer — the threat model is "buggy transform", not "hostile transform" (shard authors can already ship arbitrary hook code; see ARCHITECTURE §8). +4. Return transformed values + changelog + warnings. **Error cases**: -- Migration references key that doesn't exist → warning, skip -- Transform expression fails → warning, keep original value +- `currentVersion` or `targetVersion` not valid semver → throw `MIGRATION_INVALID_VERSION`. +- Migration references key that doesn't exist → warning, skip. +- Transform expression throws → warning, keep original value. +- Rename target already has a value → warning, both keys preserved. **Dependencies**: `semver`. --- +### 4.11 `update-planner.ts` + +**Purpose**: Pure planner that consumes the drift report + new shard + user decisions and emits a complete `UpdatePlan` describing every per-file action the executor will perform. + +**Inputs** (grouped to prevent cross-shard field mixing): +```typescript +planUpdate(input: PlanUpdateInput): Promise + +interface PlanUpdateInput { + vault: { root: string; state: ShardState; drift: DriftReport }; + values: { old: Record; new: Record }; + newShard: { + schema: ShardSchema; + selections: ModuleSelections; + tempDir: string; + renderContext: RenderContext; + filePlan?: NewFilePlan; // if already rendered, reuse to skip a pass + }; + removedFileDecisions: Record; +} +``` + +**Algorithm**: +1. If `newShard.filePlan` is supplied, reuse; otherwise call `renderNewShard` to produce the new-shard output set. Build a `Map` for O(1) lookup. +2. For each `drift.volatile` entry → emit `skip_volatile`. +3. For each `drift.managed` entry: + - Not produced by the new shard → emit `delete`. + - Produced with the same rendered hash → emit `noop`. + - Produced with a different hash → emit `overwrite` with new content. +4. For each `drift.missing` entry: + - Not in new shard → emit `delete` (state cleanup). + - In new shard → emit `restore_missing` with new content. +5. For each `drift.modified` entry (run in parallel with bounded concurrency of 16): + - Not in new shard → respect `removedFileDecisions[path]` (default `'keep'`). Emit `keep_as_user` or `delete`. + - Cached old template missing → fall back to `conflictFromDirect` (single-region full-file conflict). + - Otherwise → call `computeMergeAction`. Translate its four outcomes to `noop`/`overwrite`/`auto_merge`/`conflict` actions. Record `theirsHash` on conflict so the executor can skip re-hashing. +6. For every file in the new-shard plan not in `state.files` → emit `add`. +7. Return `{ actions, pendingConflicts, counts }`. `pendingConflicts` is the subset of `conflict` actions the state machine will drive through DiffView. + +**Key invariants**: +- Pure: no writes, no network. Only reads. +- Deterministic: same inputs → same output. Locked by a property-based test. +- Correctness of `theirsHash`: captured at plan time, used at write time — if the user edits the file between plan and write, the executor treats the captured hash as ground truth (the write has already been planned). + +**Error cases**: +- Drift reports a modified file not in `state.files` → `UPDATE_CACHE_MISSING`. Inconsistent inputs should surface loudly. + +**Dependencies**: `differ.ts`, `renderer.ts`, `modules.ts`, `fs-utils.ts` (`sha256`, `mapConcurrent`), `drift.ts` (type only). + +--- + +### 4.12 `update-executor.ts` + +**Purpose**: Apply an `UpdatePlan` against a real vault, with snapshot-based rollback. + +**Flow**: +1. Allocate a unique backup directory under `.shardmind/backups/update-[-N]/`. The millisecond-precision timestamp and numeric suffix together guarantee no collisions between concurrent or near-simultaneous updates. +2. **Snapshot**: copy every file the plan touches (modified content + `.shardmind/state.json` + cached `manifest.yaml`/`shard-schema.yaml`/`templates/`) into `files/` and `cache/` subdirectories of the backup dir. Parallel copies bounded by `SNAPSHOT_CONCURRENCY=16`. +3. **Write pass**: for each non-delete action, fire progress event, write content, update in-memory `nextFiles` map, record summary stat. `overwrite` never adds to `addedPaths` (rollback erasure list); `add` and `restore_missing` do. `keep_as_user` untracks the path from `nextFiles` so the engine stops considering it managed. +4. **Delete pass**: runs after all writes so a rename-style move (delete + add at a different path) can't clobber the incoming file. +5. **Cache + state**: call `initShardDir`, `cacheTemplates`, `cacheManifest`, `writeValuesFile`, `writeState`. Order matters — state is the last thing we touch. +6. **Hook**: call `runPostUpdateHook`. Non-fatal per Helm pattern; stdout is surfaced in the summary but does not affect exit status. +7. **Rollback**: any exception between snapshot and state-write triggers `rollbackUpdate(vaultRoot, backupDir, addedPaths)`. Removes every file in `addedPaths` (files we newly introduced), then restores every snapshotted file from `files/` and `cache/`. Idempotent — running it twice has no observable effect. + +**Dry-run mode**: +- Skips backup allocation (`backupDir` in the result is `null`). +- Skips all disk writes. +- Still computes counts and summary so the user can see "what would happen". + +**Dependencies**: `fs-utils.ts`, `state.ts` (`writeState`, `cacheTemplates`, `cacheManifest`, `initShardDir`), `install-planner.ts` (`hashValues`), `hook.ts` (`runPostUpdateHook`). + +--- + +### 4.13 `values-io.ts` + +**Purpose**: Single YAML-load path for both install's optional `--values` prefill file and update's canonical `shard-values.yaml` read. Subtle behavioral difference — install filters unknown keys against the schema, update keeps everything so migrations can handle the shape change — is a parameter, not a fork. + +**Inputs**: +```typescript +loadValuesYaml( + filePath: string, + opts: { + label: string; // embedded in error messages + schemaFilter?: ShardSchema; // filter unknown keys if set + errors: { readFailed: ErrorCode; invalid: ErrorCode }; + }, +): Promise> +``` + +Returns a plain object. Caller-supplied error codes keep each call site's hint contextual. + +--- + ## 5. Runtime Module: `shardmind/runtime` ### 5.1 `resolveVaultRoot()` @@ -702,18 +809,26 @@ interface ModuleReviewProps { ### 6.5 `DiffView.tsx` -Shows a three-way diff for a single file. Used during update for modified files with upstream changes. +Shows a three-way diff for a single file. Used during update for modified files with upstream changes. Driven one conflict at a time by the update state machine's `resolving-conflicts` phase. Props: ```typescript +export type DiffAction = 'accept_new' | 'keep_mine' | 'skip'; + interface DiffViewProps { path: string; - mergeResult: MergeResult; - onChoice: (choice: 'accept_new' | 'keep_mine' | 'open_editor' | 'skip') => void; + index: number; // 1-based position in the pending-conflicts queue + total: number; // total conflicts for this update + result: MergeResult; + onChoice: (action: DiffAction) => void; } ``` -Renders: file path header, conflict regions with color-coded sides, action buttons. +Renders: file-path header with `(N of M)` counter, each `ConflictRegion` with ±3 context lines and color-coded `yours`/`shard update` sides, a merge-stats summary (`linesUnchanged · linesAutoMerged · N regions conflicted`), and a `Select` with three active options and one disabled placeholder: Accept new · Keep mine · Skip · (Open in editor · disabled). + +CRLF-tolerant — all splits use `/\r?\n/` so a Windows-saved user file does not render `\r` characters that would corrupt the terminal. + +The "Open in editor" option is rendered disabled; its choice value is filtered by a `Set` allowlist so an accidental activation never reaches `onChoice`. Editor integration is tracked in issue #50. ### 6.6 `Header.tsx` @@ -740,7 +855,7 @@ All core functions throw typed errors: class ShardMindError extends Error { constructor( message: string, - public code: string, + public code: ErrorCode, public hint?: string, ) { super(message); @@ -755,6 +870,19 @@ throw new ShardMindError( ); ``` +`ErrorCode` is a typed union exported from `source/runtime/errors.ts`. Adding a code there forces every `new ShardMindError(msg, 'X', hint)` call site to compile-check against the union — typos surface at build time. + +Update + migration codes (added in Milestone 4): + +| Code | Thrown by | Hint pattern | +|------|-----------|--------------| +| `UPDATE_NO_INSTALL` | use-update-machine | "Run `shardmind install ` first." | +| `UPDATE_SOURCE_MISMATCH` | reserved for registry-drift detection | — | +| `UPDATE_CACHE_MISSING` | update-planner | "State and drift report disagree — re-install the shard." | +| `UPDATE_WRITE_FAILED` | update-executor | OS error message + permission / space hint | +| `MIGRATION_INVALID_VERSION` | migrator | "currentVersion and targetVersion must be valid semver." | +| `MIGRATION_TRANSFORM_FAILED` | reserved for sandbox-enforcement path | — | + Commands catch errors and render them in Ink with `StatusMessage variant="error"`. ### 7.3 Rollback on Install Failure diff --git a/source/commands/hooks/shared.ts b/source/commands/hooks/shared.ts new file mode 100644 index 0000000..51ec3f1 --- /dev/null +++ b/source/commands/hooks/shared.ts @@ -0,0 +1,87 @@ +/** + * Cross-machine utilities for install and update state hooks. + * + * Every command machine needs the same two pieces of plumbing: + * + * 1. A way to summarize a `HookResult` for the summary view, since + * both commands render the same "hook output" shape. + * 2. SIGINT handling that rolls back any in-progress mutation before + * the process dies — Ink's default exit ignores our bookkeeping. + * + * Keeping them here means install and update can't drift on the + * summary shape or the rollback policy. + */ + +import { useEffect, useRef } from 'react'; +import type { HookResult } from '../../core/hook.js'; + +export interface HookSummary { + deferred?: boolean; + stdout?: string; + exitCode?: number; +} + +export function summarizeHook(result: HookResult): HookSummary | null { + switch (result.kind) { + case 'absent': + return null; + case 'deferred': + return { deferred: true }; + case 'ran': + return { stdout: result.stdout, exitCode: result.exitCode }; + case 'failed': + return { stdout: result.message, exitCode: 1 }; + } +} + +/** + * Attach a SIGINT listener that runs `rollback()` when a mutation is + * in progress, then exits with the conventional 130 code. The caller + * supplies `isActive` so the handler can distinguish "Ctrl-C during + * network fetch" (just exit) from "Ctrl-C mid-write" (roll back first). + * + * `cleanup` runs on every Ctrl-C (active or not) — use it for things + * like deleting the downloaded-shard tempdir that must die regardless + * of whether writes had started. All callbacks swallow failures: the + * process is about to exit anyway. + * + * The handler registers ONCE on mount and deregisters on unmount. The + * callbacks are reached through refs so React doesn't thrash + * process.on/off on every render when the caller passes inline arrows. + */ +export function useSigintRollback(opts: { + isActive: () => boolean; + rollback: () => Promise; + cleanup?: () => Promise; +}): void { + // Refs hold the latest callbacks; the handler reads through them so + // it always sees current vaultRoot / backupDir / addedPaths state + // even though the handler itself is registered only once. + const isActiveRef = useRef(opts.isActive); + const rollbackRef = useRef(opts.rollback); + const cleanupRef = useRef(opts.cleanup); + isActiveRef.current = opts.isActive; + rollbackRef.current = opts.rollback; + cleanupRef.current = opts.cleanup; + + useEffect(() => { + const handler = async () => { + try { + if (isActiveRef.current()) await rollbackRef.current(); + } catch { + // swallow; process is about to exit + } + try { + const c = cleanupRef.current; + if (c) await c(); + } catch { + // swallow + } + process.exit(130); + }; + process.on('SIGINT', handler); + return () => { + process.off('SIGINT', handler); + }; + }, []); +} diff --git a/source/commands/hooks/use-install-machine.ts b/source/commands/hooks/use-install-machine.ts index c8d129a..bba8fdc 100644 --- a/source/commands/hooks/use-install-machine.ts +++ b/source/commands/hooks/use-install-machine.ts @@ -12,7 +12,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { useApp } from 'ink'; -import { parse as parseYaml } from 'yaml'; +import { loadValuesYaml } from '../../core/values-io.js'; import type { ShardManifest, @@ -42,8 +42,9 @@ import { rollbackInstall, type BackupRecord, } from '../../core/install-executor.js'; -import { runPostInstallHook, type HookResult } from '../../core/hook.js'; +import { runPostInstallHook } from '../../core/hook.js'; import { SHARDMIND_DIR, VALUES_FILE } from '../../runtime/vault-paths.js'; +import { summarizeHook, useSigintRollback, type HookSummary } from './shared.js'; import type { WizardResult } from '../../components/InstallWizard.js'; import type { CollisionAction } from '../../components/CollisionReview.js'; @@ -61,12 +62,6 @@ export interface PreparedContext { alwaysIncludedFileCount: number; } -export interface HookSummary { - deferred?: boolean; - stdout?: string; - exitCode?: number; -} - export type Phase = | { kind: 'booting' } | { kind: 'loading'; message: string } @@ -108,6 +103,9 @@ export function useInstallMachine(input: UseInstallMachineInput): UseInstallMach const writtenPathsRef = useRef([]); const backupsRef = useRef([]); const installingRef = useRef(false); + // Shard tempdir cleanup, populated once the shard download completes. + // A SIGINT between download and wizard-submit needs to run this. + const ctxCleanupRef = useRef<(() => Promise) | null>(null); // Mutable pointer to the latest handleWizardComplete closure so // runNonInteractive can call it without circular useCallback deps. const handleWizardCompleteRef = useRef<(r: WizardResult, c: PreparedContext) => Promise>( @@ -124,29 +122,18 @@ export function useInstallMachine(input: UseInstallMachineInput): UseInstallMach [exit], ); - // SIGINT handler: if a render is in progress when the user hits Ctrl+C, - // roll back partial writes and restore backups before exiting. Default - // Ink behavior exits without knowing about our bookkeeping. - useEffect(() => { - const handler = () => { - if (installingRef.current && !dryRun) { - // Fire-and-forget; process is about to exit anyway. - rollbackInstall(vaultRoot, writtenPathsRef.current, backupsRef.current) - .catch(() => {}) - .finally(() => process.exit(130)); - } else { - process.exit(130); - } - }; - process.on('SIGINT', handler); - return () => { - process.off('SIGINT', handler); - }; - }, [dryRun, vaultRoot]); + // If a render is in progress when the user hits Ctrl+C, roll back + // partial writes and restore backups before exiting. `cleanup` drops + // the shard tempdir regardless of phase — without it, cancelling at + // the wizard or collision screens leaks the extracted shard on disk. + useSigintRollback({ + isActive: () => !dryRun && installingRef.current, + rollback: () => rollbackInstall(vaultRoot, writtenPathsRef.current, backupsRef.current), + cleanup: () => (ctxCleanupRef.current ? ctxCleanupRef.current() : Promise.resolve()), + }); useEffect(() => { let disposed = false; - let ctxForCleanup: PreparedContext | null = null; (async () => { try { @@ -155,6 +142,7 @@ export function useInstallMachine(input: UseInstallMachineInput): UseInstallMach setPhase({ kind: 'loading', message: `Downloading ${resolved.namespace}/${resolved.name}@${resolved.version}…` }); const temp = await downloadShard(resolved.tarballUrl); + ctxCleanupRef.current = temp.cleanup; setPhase({ kind: 'loading', message: 'Parsing manifest and schema…' }); const manifest = await parseManifest(temp.manifest); @@ -180,7 +168,6 @@ export function useInstallMachine(input: UseInstallMachineInput): UseInstallMach moduleFileCounts, alwaysIncludedFileCount, }; - ctxForCleanup = ctx; const existing = await readState(vaultRoot); if (existing) { @@ -203,8 +190,8 @@ export function useInstallMachine(input: UseInstallMachineInput): UseInstallMach return () => { disposed = true; - if (ctxForCleanup) { - ctxForCleanup.cleanup().catch(() => {}); + if (ctxCleanupRef.current) { + ctxCleanupRef.current().catch(() => {}); } }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -400,7 +387,7 @@ export function useInstallMachine(input: UseInstallMachineInput): UseInstallMach if (choice === 'update') { finish({ kind: 'cancelled', - reason: 'Existing install preserved. `shardmind update` is not yet available (Milestone 4); re-run `install` and pick Reinstall when you want a fresh start.', + reason: 'Existing install preserved. Run `shardmind update` to pick up a newer version, or re-run `install` and pick Reinstall for a fresh start.', }); return; } @@ -464,56 +451,10 @@ async function loadValuesFile( filePath: string, schema: ShardSchema, ): Promise> { - let raw: string; - try { - raw = await fsp.readFile(filePath, 'utf-8'); - } catch (err) { - throw new ShardMindError( - `Could not read --values file: ${filePath}`, - 'VALUES_FILE_READ_FAILED', - err instanceof Error ? err.message : String(err), - ); - } - - let parsed: unknown; - try { - parsed = parseYaml(raw); - } catch (err) { - throw new ShardMindError( - `--values file is not valid YAML: ${filePath}`, - 'VALUES_FILE_INVALID', - err instanceof Error ? err.message : String(err), - ); - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new ShardMindError( - `--values file must be a YAML mapping: ${filePath}`, - 'VALUES_FILE_INVALID', - 'Top level must be { key: value } entries matching shard schema value IDs.', - ); - } - - // Unknown keys are silently ignored so a values file can be reused - // across shard versions that have added or removed entries. - const filtered: Record = {}; - for (const key of Object.keys(schema.values)) { - if (key in (parsed as Record)) { - filtered[key] = (parsed as Record)[key]; - } - } - return filtered; + return loadValuesYaml(filePath, { + label: '--values file', + schemaFilter: schema, + errors: { readFailed: 'VALUES_FILE_READ_FAILED', invalid: 'VALUES_FILE_INVALID' }, + }); } -function summarizeHook(result: HookResult): HookSummary | null { - switch (result.kind) { - case 'absent': - return null; - case 'deferred': - return { deferred: true }; - case 'ran': - return { stdout: result.stdout, exitCode: result.exitCode }; - case 'failed': - return { stdout: result.message, exitCode: 1 }; - } -} diff --git a/source/commands/hooks/use-update-machine.ts b/source/commands/hooks/use-update-machine.ts new file mode 100644 index 0000000..d92736a --- /dev/null +++ b/source/commands/hooks/use-update-machine.ts @@ -0,0 +1,592 @@ +/** + * State machine + async orchestration for the update command. + * + * Sibling of `use-install-machine.ts`. Owns every side-effecting + * transition (resolve, download, migrate, prompt, drift, plan, merge, + * write, hook, rollback) behind a hook interface so commands/update.tsx + * stays thin presentation. + * + * Phase ordering (see docs/IMPLEMENTATION.md §3): + * booting → loading → (no-install | up-to-date | migrating → + * prompt-new-values → prompt-new-modules → prompt-removed-files → + * resolving-conflicts → writing → summary) + * + * Any exception between `writing` start and `writing` end triggers + * rollback via the executor's snapshot before surfacing an error phase. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import path from 'node:path'; +import { useApp } from 'ink'; +import { loadValuesYaml } from '../../core/values-io.js'; +import type { + ShardManifest, + ShardSchema, + ShardState, + ResolvedShard, + ModuleSelections, + ModuleDefinition, + MigrationChange, +} from '../../runtime/types.js'; +import { ShardMindError } from '../../runtime/types.js'; + +import { resolve as resolveRef } from '../../core/registry.js'; +import { downloadShard } from '../../core/download.js'; +import { parseManifest } from '../../core/manifest.js'; +import { parseSchema, buildValuesValidator } from '../../core/schema.js'; +import { readState } from '../../core/state.js'; +import { detectDrift } from '../../core/drift.js'; +import { applyMigrations } from '../../core/migrator.js'; +import { + computeSchemaAdditions, + mergeModuleSelections, + removedFilesNeedingDecision, + planUpdate, + renderNewShard, + type UpdatePlan, + type ConflictResolution, + type NewFilePlan, +} from '../../core/update-planner.js'; +import { runUpdate, rollbackUpdate, type UpdateSummary } from '../../core/update-executor.js'; +import { runPostUpdateHook } from '../../core/hook.js'; +import { summarizeHook, useSigintRollback } from './shared.js'; +import { buildRenderContext } from '../../core/renderer.js'; +import { VALUES_FILE } from '../../runtime/vault-paths.js'; +import type { DiffAction } from '../../components/DiffView.js'; + +export interface UseUpdateMachineInput { + vaultRoot: string; + yes: boolean; + verbose: boolean; + dryRun: boolean; +} + +export interface PreparedContext { + state: ShardState; + oldSchema: ShardSchema; + resolved: ResolvedShard; + newManifest: ShardManifest; + newSchema: ShardSchema; + newTempDir: string; + newTarballSha: string; + cleanup: () => Promise; + oldValues: Record; + migratedValues: Record; + migrationApplied: MigrationChange[]; + migrationWarnings: string[]; + newRequiredKeys: string[]; + newOptionalModules: Array<{ id: string; def: ModuleDefinition }>; +} + +export type Phase = + | { kind: 'booting' } + | { kind: 'loading'; message: string } + | { kind: 'no-install' } + | { kind: 'up-to-date'; manifest: ShardManifest; state: ShardState } + | { kind: 'prompt-new-values'; ctx: PreparedContext } + | { kind: 'prompt-new-modules'; ctx: PreparedContext; values: Record } + | { + kind: 'prompt-removed-files'; + ctx: PreparedContext; + values: Record; + selections: ModuleSelections; + paths: string[]; + newFilePlan: NewFilePlan; + } + | { + kind: 'resolving-conflicts'; + ctx: PreparedContext; + plan: UpdatePlan; + values: Record; + selections: ModuleSelections; + currentIndex: number; + resolutions: Record; + } + | { + kind: 'writing'; + total: number; + current: number; + label: string; + history: string[]; + } + | { + kind: 'summary'; + summary: UpdateSummary; + migrationWarnings: string[]; + hook: { deferred?: boolean; stdout?: string; exitCode?: number } | null; + durationMs: number; + dryRun: boolean; + } + | { kind: 'cancelled'; reason: string } + | { kind: 'error'; error: ShardMindError | Error; detail?: string }; + +export interface UseUpdateMachineOutput { + phase: Phase; + onNewValuesComplete: (values: Record) => void; + onNewModulesComplete: (choices: Record) => void; + onRemovedFilesComplete: (decisions: Record) => void; + onConflictChoice: (action: DiffAction) => void; + onCancel: (reason?: string) => void; +} + +export function useUpdateMachine(input: UseUpdateMachineInput): UseUpdateMachineOutput { + const { vaultRoot, yes, verbose, dryRun } = input; + const { exit } = useApp(); + + const [phase, setPhase] = useState({ kind: 'booting' }); + const phaseRef = useRef(phase); + phaseRef.current = phase; + + const ctxCleanupRef = useRef<(() => Promise) | null>(null); + const backupDirRef = useRef(null); + const writingRef = useRef(false); + const addedPathsRef = useRef([]); + + const finish = useCallback( + (next: Phase) => { + setPhase(next); + if ( + next.kind === 'summary' || + next.kind === 'cancelled' || + next.kind === 'error' || + next.kind === 'no-install' || + next.kind === 'up-to-date' + ) { + setTimeout(() => exit(), 100); + } + }, + [exit], + ); + + // If we're mid-write, walk the executor's snapshot back before exiting. + // Tempdir cleanup fires on every Ctrl-C — otherwise cancelling during the + // download/plan phase would leak the extracted shard on disk. + // `rollbackUpdate` returns a failure list; we ignore it here (the + // process is about to exit), but SIGINT-mid-write is rare enough that + // the silent path is acceptable — the disk state is best-effort anyway. + useSigintRollback({ + isActive: () => !dryRun && writingRef.current && backupDirRef.current !== null, + rollback: async () => { + if (backupDirRef.current) { + await rollbackUpdate(vaultRoot, backupDirRef.current, addedPathsRef.current); + } + }, + cleanup: () => (ctxCleanupRef.current ? ctxCleanupRef.current() : Promise.resolve()), + }); + + // Boot pipeline: state → resolve → download → parse → migrate → branch. + useEffect(() => { + let disposed = false; + + (async () => { + try { + setPhase({ kind: 'loading', message: 'Reading install state…' }); + const state = await readState(vaultRoot); + if (!state) { + finish({ kind: 'no-install' }); + return; + } + + setPhase({ kind: 'loading', message: `Resolving ${state.source}…` }); + const resolved = await resolveRef(state.source); + + setPhase({ + kind: 'loading', + message: `Downloading ${resolved.namespace}/${resolved.name}@${resolved.version}…`, + }); + const temp = await downloadShard(resolved.tarballUrl); + ctxCleanupRef.current = temp.cleanup; + + setPhase({ kind: 'loading', message: 'Parsing new manifest and schema…' }); + const newManifest = await parseManifest(temp.manifest); + const newSchema = await parseSchema(temp.schema); + + if ( + newManifest.version === state.version && + temp.tarball_sha256 === state.tarball_sha256 + ) { + if (disposed) return; + finish({ kind: 'up-to-date', manifest: newManifest, state }); + return; + } + + setPhase({ kind: 'loading', message: 'Loading current values…' }); + const oldValues = await loadCurrentValues(vaultRoot); + const oldSchema = await loadCachedSchema(vaultRoot, state); + + setPhase({ kind: 'loading', message: 'Applying migrations…' }); + const migration = applyMigrations( + oldValues, + state.version, + newManifest.version, + newSchema.migrations, + ); + + const additions = computeSchemaAdditions(newSchema, state.modules, migration.values); + + const ctx: PreparedContext = { + state, + oldSchema, + resolved, + newManifest, + newSchema, + newTempDir: temp.tempDir, + newTarballSha: temp.tarball_sha256, + cleanup: temp.cleanup, + oldValues, + migratedValues: migration.values, + migrationApplied: migration.applied, + migrationWarnings: migration.warnings, + newRequiredKeys: additions.newRequiredKeys, + newOptionalModules: additions.newOptionalModules, + }; + + if (disposed) return; + + if (yes) { + await runNonInteractive(ctx); + return; + } + + if (ctx.newRequiredKeys.length > 0) { + setPhase({ kind: 'prompt-new-values', ctx }); + return; + } + continueAfterValues(ctx, ctx.migratedValues); + } catch (err) { + if (disposed) return; + finish({ kind: 'error', error: err as Error }); + } + })(); + + return () => { + disposed = true; + if (ctxCleanupRef.current) { + ctxCleanupRef.current().catch(() => {}); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vaultRoot, yes]); + + const runNonInteractive = useCallback( + async (ctx: PreparedContext) => { + if (ctx.newRequiredKeys.length > 0) { + throw new ShardMindError( + `Missing required values for --yes: ${ctx.newRequiredKeys.join(', ')}`, + 'VALUES_MISSING', + 'Drop --yes and answer interactively, or add the missing keys to shard-values.yaml first.', + ); + } + const selections = mergeModuleSelections( + ctx.state.modules, + ctx.newSchema, + Object.fromEntries(ctx.newOptionalModules.map((m) => [m.id, 'included'])), + ); + const values = validateValues(ctx.newSchema, ctx.migratedValues); + await continueWithRemovedPrompt(ctx, values, selections); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const continueAfterValues = useCallback( + (ctx: PreparedContext, values: Record) => { + const validated = validateValues(ctx.newSchema, values); + if (ctx.newOptionalModules.length > 0) { + setPhase({ kind: 'prompt-new-modules', ctx, values: validated }); + return; + } + const selections = mergeModuleSelections(ctx.state.modules, ctx.newSchema, {}); + void continueWithRemovedPrompt(ctx, validated, selections); + }, + [], + ); + + const continueWithRemovedPrompt = useCallback( + async ( + ctx: PreparedContext, + values: Record, + selections: ModuleSelections, + ) => { + try { + // Render the new shard once and thread the result through to + // `runPlanAndResolve`. The planner reuses this plan instead of + // rendering a second time. + const newRenderContext = buildRenderContext(ctx.newManifest, values, selections); + const [drift, newFilePlan] = await Promise.all([ + detectDrift(vaultRoot, ctx.state), + renderNewShard(ctx.newSchema, ctx.newTempDir, selections, newRenderContext), + ]); + const newPaths = new Set(newFilePlan.outputs.map((o) => o.outputPath)); + const removedModified = removedFilesNeedingDecision(drift, newPaths); + + if (removedModified.length === 0 || yes) { + await runPlanAndResolve(ctx, values, selections, {}, { drift, newFilePlan }); + return; + } + setPhase({ + kind: 'prompt-removed-files', + ctx, + values, + selections, + paths: removedModified, + newFilePlan, + }); + } catch (err) { + finish({ kind: 'error', error: err as Error }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [vaultRoot, yes, finish], + ); + + const runPlanAndResolve = useCallback( + async ( + ctx: PreparedContext, + values: Record, + selections: ModuleSelections, + removedDecisions: Record, + precomputed?: { drift?: Awaited>; newFilePlan?: NewFilePlan }, + ) => { + try { + setPhase({ kind: 'loading', message: 'Planning update…' }); + const newRenderContext = buildRenderContext(ctx.newManifest, values, selections); + const drift = precomputed?.drift ?? (await detectDrift(vaultRoot, ctx.state)); + const plan = await planUpdate({ + vault: { root: vaultRoot, state: ctx.state, drift }, + values: { old: ctx.oldValues, new: values }, + newShard: { + schema: ctx.newSchema, + selections, + tempDir: ctx.newTempDir, + renderContext: newRenderContext, + filePlan: precomputed?.newFilePlan, + }, + removedFileDecisions: removedDecisions, + }); + + if (plan.pendingConflicts.length > 0 && !yes) { + setPhase({ + kind: 'resolving-conflicts', + ctx, + plan, + values, + selections, + currentIndex: 0, + resolutions: {}, + }); + return; + } + + // --yes: auto-resolve conflicts by keeping the user's copy. + const autoResolutions: Record = {}; + for (const pc of plan.pendingConflicts) { + autoResolutions[pc.path] = 'keep_mine'; + } + await executeWrite(ctx, plan, values, selections, autoResolutions); + } catch (err) { + finish({ kind: 'error', error: err as Error }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [vaultRoot, yes, finish], + ); + + const executeWrite = useCallback( + async ( + ctx: PreparedContext, + plan: UpdatePlan, + values: Record, + selections: ModuleSelections, + resolutions: Record, + ) => { + const start = Date.now(); + const history: string[] = []; + + setPhase({ kind: 'writing', total: 0, current: 0, label: 'Preparing…', history }); + writingRef.current = true; + addedPathsRef.current = []; + backupDirRef.current = null; + + try { + const result = await runUpdate({ + vaultRoot, + plan, + conflictResolutions: resolutions, + currentState: ctx.state, + newManifest: ctx.newManifest, + newSchema: ctx.newSchema, + newValues: values, + newSelections: selections, + resolved: ctx.resolved, + tarballSha256: ctx.newTarballSha, + newTempDir: ctx.newTempDir, + dryRun, + // Populate the refs EAGERLY so a mid-write SIGINT can actually + // find the backup dir and the list of paths to erase. The + // post-runUpdate assignment below still runs for the + // non-cancelled success path; these streaming callbacks make + // the same values available while the run is in flight. + onBackupReady: (dir) => { + backupDirRef.current = dir; + }, + onFileTouched: (_outputPath, introduced) => { + if (introduced) addedPathsRef.current.push(_outputPath); + }, + onProgress: (ev) => { + if (ev.kind === 'start') { + setPhase((prev) => + prev.kind === 'writing' ? { ...prev, total: ev.total, current: 0, label: 'Starting…' } : prev, + ); + } else if (ev.kind === 'file') { + if (verbose) { + history.push(`${labelForAction(ev.action)} ${ev.outputPath}`); + if (history.length > 5) history.shift(); + } + setPhase((prev) => { + if (prev.kind !== 'writing') return prev; + return { + ...prev, + current: ev.index, + total: ev.total, + label: ev.outputPath, + history: verbose ? [...history] : prev.history, + }; + }); + } + }, + }); + backupDirRef.current = result.backupDir; + + const hookResult = dryRun + ? { kind: 'absent' as const } + : await runPostUpdateHook(ctx.newTempDir, ctx.newManifest); + + writingRef.current = false; + + finish({ + kind: 'summary', + summary: result.summary, + migrationWarnings: ctx.migrationWarnings, + hook: summarizeHook(hookResult), + durationMs: Date.now() - start, + dryRun, + }); + } catch (err) { + writingRef.current = false; + finish({ + kind: 'error', + error: err as Error, + detail: dryRun ? undefined : 'Rolled back partial update.', + }); + } + }, + [vaultRoot, dryRun, verbose, finish], + ); + + const onNewValuesComplete = useCallback( + (values: Record) => { + const current = phaseRef.current; + if (current.kind !== 'prompt-new-values') return; + try { + continueAfterValues(current.ctx, { ...current.ctx.migratedValues, ...values }); + } catch (err) { + finish({ kind: 'error', error: err as Error }); + } + }, + [continueAfterValues, finish], + ); + + const onNewModulesComplete = useCallback( + (choices: Record) => { + const current = phaseRef.current; + if (current.kind !== 'prompt-new-modules') return; + const selections = mergeModuleSelections(current.ctx.state.modules, current.ctx.newSchema, choices); + void continueWithRemovedPrompt(current.ctx, current.values, selections); + }, + [continueWithRemovedPrompt], + ); + + const onRemovedFilesComplete = useCallback( + (decisions: Record) => { + const current = phaseRef.current; + if (current.kind !== 'prompt-removed-files') return; + void runPlanAndResolve(current.ctx, current.values, current.selections, decisions, { + newFilePlan: current.newFilePlan, + }); + }, + [runPlanAndResolve], + ); + + const onConflictChoice = useCallback( + (action: DiffAction) => { + const current = phaseRef.current; + if (current.kind !== 'resolving-conflicts') return; + const pc = current.plan.pendingConflicts[current.currentIndex]; + if (!pc) return; + const nextResolutions = { ...current.resolutions, [pc.path]: action }; + const nextIndex = current.currentIndex + 1; + if (nextIndex < current.plan.pendingConflicts.length) { + setPhase({ ...current, currentIndex: nextIndex, resolutions: nextResolutions }); + return; + } + void executeWrite(current.ctx, current.plan, current.values, current.selections, nextResolutions); + }, + [executeWrite], + ); + + const onCancel = useCallback( + (reason: string = 'User cancelled.') => finish({ kind: 'cancelled', reason }), + [finish], + ); + + return { + phase, + onNewValuesComplete, + onNewModulesComplete, + onRemovedFilesComplete, + onConflictChoice, + onCancel, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function loadCurrentValues(vaultRoot: string): Promise> { + return loadValuesYaml(path.join(vaultRoot, VALUES_FILE), { + label: VALUES_FILE, + errors: { readFailed: 'VALUES_READ_FAILED', invalid: 'VALUES_INVALID' }, + }); +} + +async function loadCachedSchema(vaultRoot: string, state: ShardState): Promise { + try { + return await parseSchema(path.join(vaultRoot, '.shardmind', 'shard-schema.yaml')); + } catch (err) { + throw new ShardMindError( + 'Cached schema missing or corrupt', + 'UPDATE_CACHE_MISSING', + `Re-run \`shardmind install ${state.source}\` to regenerate .shardmind/. ` + + `Original error: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +function validateValues(schema: ShardSchema, values: Record): Record { + const validator = buildValuesValidator(schema); + return validator.parse(values) as Record; +} + +function labelForAction(kind: string): string { + switch (kind) { + case 'overwrite': return '↻'; + case 'auto_merge': return '⚙'; + case 'conflict': return '✎'; + case 'add': return '+'; + case 'delete': return '✗'; + case 'restore_missing': return '↺'; + default: return '·'; + } +} diff --git a/source/commands/install.tsx b/source/commands/install.tsx index c80ea0f..29e19f3 100644 --- a/source/commands/install.tsx +++ b/source/commands/install.tsx @@ -1,4 +1,3 @@ -import { type ReactNode } from 'react'; import { Box, Text } from 'ink'; import { Spinner, StatusMessage, Alert } from '../components/ui.js'; import zod from 'zod'; @@ -8,8 +7,9 @@ import { ShardMindError } from '../runtime/types.js'; import InstallWizard from '../components/InstallWizard.js'; import CollisionReview from '../components/CollisionReview.js'; import ExistingInstallGate from '../components/ExistingInstallGate.js'; -import InstallProgress from '../components/InstallProgress.js'; +import CommandProgress from '../components/CommandProgress.js'; import Summary from '../components/Summary.js'; +import CommandFrame from '../components/CommandFrame.js'; import { useInstallMachine } from './hooks/use-install-machine.js'; @@ -52,26 +52,26 @@ export default function Install({ args, options }: Props) { if (phase.kind === 'booting' || phase.kind === 'loading') { const msg = phase.kind === 'loading' ? phase.message : 'Starting…'; return ( - + {msg} - + ); } if (phase.kind === 'gate') { return ( - + - + ); } if (phase.kind === 'wizard') { return ( - + - + ); } if (phase.kind === 'collision') { return ( - + - + ); } if (phase.kind === 'installing') { return ( - - + - + ); } if (phase.kind === 'summary') { return ( - + - + ); } if (phase.kind === 'cancelled') { return ( - + Cancelled {phase.reason} - + ); } @@ -139,45 +139,14 @@ export default function Install({ args, options }: Props) { const code = err instanceof ShardMindError ? err.code : null; const hint = err instanceof ShardMindError ? err.hint : null; return ( - + {err.message} {code && code: {code}} {hint && {hint}} {phase.detail && {phase.detail}} - - ); -} - -function RootFrame({ - children, - dryRun, - showLegend = true, -}: { - children: ReactNode; - dryRun: boolean; - showLegend?: boolean; -}) { - return ( - - {dryRun && ( - - - {' DRY RUN '} - - no files will be written - - )} - {children} - {showLegend && ( - - - ↑↓ navigate · Space select (multi) · Enter confirm · Esc back · Ctrl+C cancel - - - )} - + ); } diff --git a/source/commands/update.tsx b/source/commands/update.tsx new file mode 100644 index 0000000..f4757d5 --- /dev/null +++ b/source/commands/update.tsx @@ -0,0 +1,192 @@ +import { Box, Text } from 'ink'; +import zod from 'zod'; + +import { Spinner, StatusMessage, Alert } from '../components/ui.js'; +import { ShardMindError } from '../runtime/types.js'; + +import DiffView from '../components/DiffView.js'; +import NewValuesPrompt from '../components/NewValuesPrompt.js'; +import NewModulesReview from '../components/NewModulesReview.js'; +import RemovedFilesReview from '../components/RemovedFilesReview.js'; +import CommandProgress from '../components/CommandProgress.js'; +import UpdateSummary from '../components/UpdateSummary.js'; +import CommandFrame from '../components/CommandFrame.js'; +import Header from '../components/Header.js'; + +import { useUpdateMachine } from './hooks/use-update-machine.js'; + +export const options = zod.object({ + yes: zod.boolean().default(false).describe('Accept defaults for every prompt (auto-keeps conflicts)'), + verbose: zod.boolean().default(false).describe('Show per-file action history during write'), + dryRun: zod.boolean().default(false).describe('Plan the update without touching the vault'), +}); + +type Props = { + options: zod.infer; +}; + +export default function Update({ options }: Props) { + const { yes, verbose, dryRun } = options; + + const { + phase, + onNewValuesComplete, + onNewModulesComplete, + onRemovedFilesComplete, + onConflictChoice, + } = useUpdateMachine({ + vaultRoot: process.cwd(), + yes, + verbose, + dryRun, + }); + + if (phase.kind === 'booting' || phase.kind === 'loading') { + const msg = phase.kind === 'loading' ? phase.message : 'Starting…'; + return ( + + + + {msg} + + + ); + } + + if (phase.kind === 'no-install') { + return ( + + + + No shard installed in this directory. + + + Run shardmind install <shard> first, then come back to update. + + + + ); + } + + if (phase.kind === 'up-to-date') { + return ( + + +
+ + Already up to date at v{phase.state.version}. + + + + ); + } + + if (phase.kind === 'prompt-new-values') { + return ( + +
+ + + ); + } + + if (phase.kind === 'prompt-new-modules') { + return ( + +
+ + + ); + } + + if (phase.kind === 'prompt-removed-files') { + return ( + +
+ + + ); + } + + if (phase.kind === 'resolving-conflicts') { + const pending = phase.plan.pendingConflicts[phase.currentIndex]; + if (!pending) return null; + return ( + + + + ); + } + + if (phase.kind === 'writing') { + return ( + + + + ); + } + + if (phase.kind === 'summary') { + return ( + + + + ); + } + + if (phase.kind === 'cancelled') { + return ( + + + Cancelled + {phase.reason} + + + ); + } + + const err = phase.error; + const code = err instanceof ShardMindError ? err.code : null; + const hint = err instanceof ShardMindError ? err.hint : null; + return ( + + + {err.message} + {code && code: {code}} + {hint && {hint}} + {phase.detail && {phase.detail}} + + + ); +} + +export const description = 'Update the installed shard to its latest version'; diff --git a/source/components/CommandFrame.tsx b/source/components/CommandFrame.tsx new file mode 100644 index 0000000..376fb17 --- /dev/null +++ b/source/components/CommandFrame.tsx @@ -0,0 +1,34 @@ +import { type ReactNode } from 'react'; +import { Box, Text } from 'ink'; + +/** + * Outer shell for a top-level command (install, update). Renders the + * dry-run banner above children and the keyboard-legend hint below, + * so command files only own their phase content — not the chrome. + */ +interface CommandFrameProps { + children: ReactNode; + dryRun: boolean; + showLegend?: boolean; +} + +export default function CommandFrame({ children, dryRun, showLegend = true }: CommandFrameProps) { + return ( + + {dryRun && ( + + {' DRY RUN '} + no files will be written + + )} + {children} + {showLegend && ( + + + ↑↓ navigate · Space select (multi) · Enter confirm · Esc back · Ctrl+C cancel + + + )} + + ); +} diff --git a/source/components/InstallProgress.tsx b/source/components/CommandProgress.tsx similarity index 76% rename from source/components/InstallProgress.tsx rename to source/components/CommandProgress.tsx index 2660a98..6eb71f2 100644 --- a/source/components/InstallProgress.tsx +++ b/source/components/CommandProgress.tsx @@ -1,7 +1,12 @@ import { Box, Text } from 'ink'; import { ProgressBar, Spinner } from './ui.js'; -interface InstallProgressProps { +/** + * Progress indicator shared by install and update commands: spinner + + * counter + ProgressBar, with an optional rolling history footer for + * verbose mode. + */ +interface CommandProgressProps { current: number; total: number; label: string; @@ -9,13 +14,13 @@ interface InstallProgressProps { history?: string[]; } -export default function InstallProgress({ +export default function CommandProgress({ current, total, label, verbose, history, -}: InstallProgressProps) { +}: CommandProgressProps) { const percent = total === 0 ? 0 : Math.min(100, Math.round((current / total) * 100)); return ( diff --git a/source/components/DiffView.tsx b/source/components/DiffView.tsx new file mode 100644 index 0000000..4cd16e9 --- /dev/null +++ b/source/components/DiffView.tsx @@ -0,0 +1,119 @@ +import { useMemo, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { Select } from './ui.js'; +import type { ConflictRegion, MergeResult } from '../runtime/types.js'; + +/** Conflict-resolution choices returned to the state machine. */ +export type DiffAction = 'accept_new' | 'keep_mine' | 'skip'; + +/** Matches differ.ts's canonical splitter: tolerate CR, accept LF. */ +const LINE_SPLIT = /\r?\n/; + +/** Context lines shown before and after each conflict region. */ +const CONTEXT_LINES = 3; + +/** + * `Select` accepts arbitrary string values; we use the type-guarded + * lookup below to filter the disabled "Open in editor" placeholder so + * no out-of-band value reaches `onChoice`. + */ +const DIFF_ACTIONS = new Set(['accept_new', 'keep_mine', 'skip']); + +const SELECT_OPTIONS = [ + { label: 'Accept new (use shard version)', value: 'accept_new' }, + { label: 'Keep mine (preserve your edits)', value: 'keep_mine' }, + { label: 'Skip this file', value: 'skip' }, + { label: '(Open in editor · v0.2)', value: 'open_editor_disabled' }, +] as const; + +interface DiffViewProps { + path: string; + index: number; + total: number; + result: MergeResult; + onChoice: (action: DiffAction) => void; +} + +export default function DiffView({ path: filePath, index, total, result, onChoice }: DiffViewProps) { + const mergedLines = useMemo(() => result.content.split(LINE_SPLIT), [result.content]); + // `Select` may fire onChange more than once if Ink re-focuses the + // instance; once the user's pick is in, ignore everything else for + // this mount. `key={filePath}` below forces a fresh ref per file so + // this only blocks same-file duplicates. + const firedRef = useRef(false); + + return ( + + + Conflict in + {filePath} + ({index} of {total}) + + + + {result.conflicts.map((region, i) => ( + + ))} + + + + {result.stats.linesUnchanged} unchanged · {result.stats.linesAutoMerged} auto-merged ·{' '} + {result.conflicts.length} region{result.conflicts.length === 1 ? '' : 's'} conflicted + + + onSubmit({})} + /> + + ); + } + + return ( + + New modules offered + Space to toggle, Enter to confirm. All included by default. + ({ label: `${m.def.label ?? m.id}`, value: m.id }))} + defaultValue={selected} + onChange={(next) => setSelected(next)} + onSubmit={(next) => { + const choices: Record = {}; + for (const { id } of offered) { + choices[id] = next.includes(id) ? 'included' : 'excluded'; + } + onSubmit(choices); + }} + /> + + ); +} diff --git a/source/components/NewValuesPrompt.tsx b/source/components/NewValuesPrompt.tsx new file mode 100644 index 0000000..98242f2 --- /dev/null +++ b/source/components/NewValuesPrompt.tsx @@ -0,0 +1,66 @@ +import { useState, useMemo, useRef } from 'react'; +import { Box, Text } from 'ink'; +import ValueInput from './ValueInput.js'; +import type { ShardSchema } from '../runtime/types.js'; + +interface NewValuesPromptProps { + schema: ShardSchema; + keys: string[]; + existingValues: Record; + onComplete: (collected: Record) => void; +} + +/** + * Asks only for value keys the migration couldn't fill in. Mirrors the + * shape of the install wizard's value phase but without the header, + * computed-preview, and confirm steps — those belong to install's + * first-run ceremony, not an update. + * + * `ValueInput` remounts per step because it is keyed on `id` internally, + * so its input state resets cleanly between questions. `completedRef` + * guards against a re-entrant submit that would fire `onComplete` twice. + */ +export default function NewValuesPrompt({ + schema, + keys, + existingValues, + onComplete, +}: NewValuesPromptProps) { + const defs = useMemo( + () => keys.map((k) => [k, schema.values[k]!] as const), + [keys, schema.values], + ); + const [index, setIndex] = useState(0); + const [values, setValues] = useState>(existingValues); + const completedRef = useRef(false); + + const entry = defs[index]; + if (!entry) return null; + + const [key, def] = entry; + return ( + + New values since your last install + + Step {index + 1} of {defs.length} + {def.group ? ` · ${def.group}` : ''} + + { + if (completedRef.current) return; + const next = { ...values, [key]: v }; + if (index + 1 >= defs.length) { + completedRef.current = true; + onComplete(next); + return; + } + setValues(next); + setIndex(index + 1); + }} + /> + + ); +} diff --git a/source/components/RemovedFilesReview.tsx b/source/components/RemovedFilesReview.tsx new file mode 100644 index 0000000..4bfe8e5 --- /dev/null +++ b/source/components/RemovedFilesReview.tsx @@ -0,0 +1,62 @@ +import { useState, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { Select } from './ui.js'; + +interface RemovedFilesReviewProps { + paths: string[]; + onSubmit: (decisions: Record) => void; +} + +/** + * One file at a time: "this file was in the old shard and you edited it, + * but the new shard no longer ships it. Keep your version or delete?" + * + * Only modified files surface here — managed removals auto-delete silently. + * + * Implementation notes: + * - `Select` carries internal highlight state across re-renders of the + * same tree position. Keying it on `filePath` forces a remount per + * file so every prompt starts fresh on "Keep". + * - `submittedRef` guards against a re-entrant render firing `onSubmit` + * a second time after we've already committed the final decision. + */ +export default function RemovedFilesReview({ paths, onSubmit }: RemovedFilesReviewProps) { + const [index, setIndex] = useState(0); + const [decisions, setDecisions] = useState>({}); + const submittedRef = useRef(false); + + const filePath = paths[index]; + if (!filePath) return null; + + return ( + + Removed by new shard + + {filePath} + ({index + 1} of {paths.length}) + + + You edited this file and the new shard no longer ships it. Keep your version + (it stops being tracked) or delete it? + +