feat: update command — migrator, planner, executor, DiffView (#12)#52
feat: update command — migrator, planner, executor, DiffView (#12)#52breferrari merged 13 commits intomainfrom
Conversation
Adds the fourth and final engine pipeline module: applyMigrations walks rename/added/removed/type_changed changes whose from_version falls in (currentVersion, targetVersion], preserving values on best-effort anomalies (missing keys, throwing transforms) so a single bad migration can't block the upgrade. Zod validation against the new schema still catches real breakage after the pass. 16 unit tests cover each change type, version-range selection, warn-only semantics, and a multi-version chain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
planUpdate maps the DriftReport + schema diff + new-shard render into a list of per-file UpdateActions: overwrite managed, three-way merge modified, add new, delete/keep-as-user removed, restore missing, skip volatile. Pending conflicts are surfaced as a separate list so the state machine can drive DiffView between planner and executor. Also exports three small helpers the state machine needs before planning: computeSchemaAdditions (new required keys + offered modules), mergeModuleSelections (carry-over + opt-in), and removedFilesNeedingDecision (only modified removals need a prompt). 17 unit tests cover each action branch against a synthetic temp vault. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
runUpdate applies an UpdatePlan against a real vault: snapshot every path it will touch (files + .shardmind cache) into .shardmind/backups/update-<ts>/, write/merge/delete per action, re-cache manifest + schema + templates, write new state.json, run the post-update hook. Any failure between snapshot and state write triggers rollbackUpdate, which restores the snapshot tree and removes files added mid-flight. Dry-run path skips all writes and the snapshot. Adds runPostUpdateHook alongside runPostInstallHook — same deferred execution contract (#30) covers both. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six new components for the update flow: - DiffView — one-conflict-at-a-time viewer with ±3 lines of context, color-coded yours/shard regions, and per-file action select (accept new / keep mine / skip; editor integration stubbed for v0.2). - NewValuesPrompt — asks only for value keys that migrations couldn't fill (thin wrapper over ValueInput). - NewModulesReview — multiselect for modules present in the new shard but absent from the prior install. - RemovedFilesReview — per-file keep-or-delete decision for modified files dropped from the new shard; managed removals auto-delete. - UpdateProgress — progress bar + verbose history (mirrors InstallProgress). - UpdateSummary — from→to line, action counts, conflict resolution breakdown, migration warnings, hook output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling of commands/install.tsx. useUpdateMachine owns every
side-effecting transition behind a hook interface so update.tsx stays
thin presentation:
booting → loading → (no-install | up-to-date | migrating →
prompt-new-values → prompt-new-modules → prompt-removed-files →
resolving-conflicts → writing → summary)
SIGINT handler reads the executor's backup dir to roll back mid-write.
--yes auto-keeps conflicts and opts into every new optional module so
CI upgrades are unattended-friendly.
Adds commands/update to tsup.config entry points and refreshes the
install machine's now-inaccurate "update not yet available" hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six tests against examples/minimal-shard that drive install → modify → detectDrift → applyMigrations → planUpdate → runUpdate against real filesystem state (no network): - managed file silently overwritten on version bump with template change - modified file with non-overlapping edits three-way merges cleanly - modified file with overlapping edits surfaces a pending conflict, keep_mine preserves the user's version and marks it 'modified' in state.json - up-to-date precondition (same version + same tarball sha) - new required-value detection via computeSchemaAdditions - removedFilesNeedingDecision flags modified files dropped from the new shard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#12 shipped. Status (#13) is a separate PR. Full CLI E2E comes with M6 (#15 — publish) where we already planned it. Two v0.2 follow-ups surfaced while scoping #12: - #50 — $EDITOR integration for DiffView (v0.1 stubbed the option). - #51 — 24h update-check cache shared between status (#13) and update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds the v0.1 shardmind update pipeline to the CLI/TUI, including value migrations, a pure update planner, a rollback-capable executor, and Ink UI for resolving merge conflicts—backed by new unit + integration tests.
Changes:
- Introduces core update engine modules:
migrator,update-planner, andupdate-executor(with backups + rollback). - Adds
shardmind updatecommand with auseUpdateMachineorchestrator and Ink components (DiffView, prompts, progress, summary). - Expands tests with unit coverage for migrations/planning and an end-to-end update pipeline integration suite.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tsup.config.ts | Emits dist/commands/update.js for Pastel routing. |
| source/runtime/errors.ts | Adds update/migration-related ShardMindError codes. |
| source/core/migrator.ts | Applies schema migrations between shard versions. |
| source/core/update-planner.ts | Renders new shard outputs and produces an UpdatePlan + pending conflicts. |
| source/core/update-executor.ts | Executes the plan with snapshot/rollback and writes updated state/values/cache. |
| source/core/hook.ts | Adds post-update hook detection (runPostUpdateHook). |
| source/components/DiffView.tsx | Renders conflict regions and collects per-file conflict decisions. |
| source/components/NewValuesPrompt.tsx | Prompts for newly-required values missing after migration. |
| source/components/NewModulesReview.tsx | Prompts for opting into new optional modules. |
| source/components/RemovedFilesReview.tsx | Prompts for modified files removed by the new shard. |
| source/components/UpdateProgress.tsx | Shows progress bar + optional history while writing. |
| source/components/UpdateSummary.tsx | Summarizes update results and migration warnings. |
| source/commands/update.tsx | New shardmind update command UI wiring. |
| source/commands/hooks/use-update-machine.ts | Orchestrates the full update flow (resolve/download/migrate/plan/execute). |
| source/commands/hooks/use-install-machine.ts | Updates messaging to point users at shardmind update. |
| ROADMAP.md | Marks update command + integration test as completed; records deferrals. |
| tests/unit/migrator.test.ts | Unit tests for migration selection + change application. |
| tests/unit/update-planner.test.ts | Unit/integration-style tests for planner behavior. |
| tests/integration/update.test.ts | End-to-end update pipeline scenarios against examples/minimal-shard. |
| // Missing: file recorded in state but gone from disk. If the new shard | ||
| // still ships it, restore. If it also disappeared upstream, leave alone. | ||
| for (const entry of drift.missing) { | ||
| const target = newByPath.get(entry.path); | ||
| if (!target) { | ||
| actions.push({ kind: 'delete', path: entry.path }); | ||
| counts.deleted++; |
There was a problem hiding this comment.
The comment says "If it also disappeared upstream, leave alone", but the code emits a delete action (which will remove the entry from state and attempt an rm). If the intended behavior is to stop tracking missing files that the shard no longer produces, consider updating the comment to match; otherwise adjust the logic.
There was a problem hiding this comment.
Stale — the misleading comment is already gone. The current drift.missing branch (update-planner.ts:328–344) has no such text; it emits delete when the new shard no longer produces the file, restore_missing otherwise. No action needed on current HEAD.
| // SIGINT: if we're mid-write, use the backup dir to roll back. | ||
| useEffect(() => { | ||
| const handler = () => { | ||
| if (writingRef.current && backupDirRef.current && !dryRun) { | ||
| rollbackUpdate(vaultRoot, backupDirRef.current, addedPathsRef.current) | ||
| .catch(() => {}) | ||
| .finally(() => process.exit(130)); | ||
| } else { | ||
| process.exit(130); | ||
| } |
There was a problem hiding this comment.
The SIGINT rollback path is effectively unreachable right now: backupDirRef.current is only assigned after runUpdate resolves, but Ctrl+C happens while runUpdate is in flight. Also addedPathsRef.current is never populated from the executor, so even if rollback ran it wouldn't remove newly-added files. Consider having runUpdate (or the executor) surface backupDir immediately (callback/progress event) and stream added paths so the SIGINT handler can reliably roll back mid-run.
There was a problem hiding this comment.
SIGINT rollback path is now reachable. Fixed in commit d600dab: the executor exposes onBackupReady (fires after snapshot is staged, before any write) and onFileTouched (fires per write with an introduced boolean). The state machine populates backupDirRef + addedPathsRef eagerly from those callbacks, so a mid-write Ctrl-C now finds live rollback state. Integration test fires onBackupReady before any writes, so SIGINT can roll back mid-run locks the ordering.
| /** | ||
| * Overwrite `shard-values.yaml` (the install executor uses `wx` so it | ||
| * refuses to overwrite; here we *want* to replace it atomically). | ||
| */ | ||
| async function writeValuesFile( |
There was a problem hiding this comment.
This comment claims the update overwrites shard-values.yaml "atomically", but the implementation uses a direct writeFile to the final path. If atomicity matters (e.g., crash safety), consider writing to a temp file in the same directory and rename-ing, or adjust the comment to avoid promising atomic semantics.
There was a problem hiding this comment.
Fixed in d600dab — the docstring on writeValuesFile now says the atomicity comes from the snapshot-based rollback, not the write itself. Not switching to temp+rename because rollback already covers the crash-during-write case; added the note so the claim doesn't overpromise.
| const writable = plan.actions.filter((a) => actionWrites(a, conflictResolutions)); | ||
| onProgress?.({ kind: 'start', total: writable.length }); | ||
|
|
||
| const nextFiles: Record<string, FileState> = { ...currentState.files }; | ||
| const summary: UpdateSummary = { | ||
| fromVersion: currentState.version, | ||
| toVersion: newManifest.version, | ||
| counts: plan.counts, | ||
| conflictsResolved: 0, | ||
| conflictsKeptMine: 0, | ||
| conflictsSkipped: 0, | ||
| conflictsAcceptedNew: 0, | ||
| autoMergeStats: { linesUnchanged: 0, linesAutoMerged: 0 }, | ||
| wroteFiles: [], | ||
| deletedFiles: [], | ||
| }; | ||
|
|
||
| // Two-pass: writes first, deletes second. Writes use mkdir -p so they | ||
| // can create parents. Deletes after means a rename-style move (delete | ||
| // + add at a different path) won't clobber a new file. | ||
| let index = 0; | ||
| for (const action of plan.actions) { | ||
| if (isDeleteAction(action, conflictResolutions)) continue; | ||
| await applyWriteAction(action, { | ||
| vaultRoot, | ||
| conflictResolutions, | ||
| nextFiles, | ||
| summary, | ||
| addedPaths, | ||
| dryRun, | ||
| onProgress, | ||
| index: ++index, | ||
| total: writable.length, | ||
| }); |
There was a problem hiding this comment.
writable is computed via actionWrites, but the executor still iterates all non-delete actions and emits progress events for them. For conflicts resolved as keep_mine/skip, actionWrites returns false, so total can be smaller than the number of progress updates and index can exceed total (progress bar can go past 100%). Consider defining total as the number of actions you will actually process in these loops, or skip processing/emitting progress for actions you exclude from writable.
There was a problem hiding this comment.
Real bug, fixed in d600dab. Added actionEmitsProgress helper (covers conflict regardless of resolution) and use it for progressTotal instead of writable.length. actionWrites was retired since its only callsite was the broken total calculation.
| if (!ctx.dryRun) { | ||
| const wasExisting = await pathExists(path.join(ctx.vaultRoot, action.path)); | ||
| await writeFile(ctx.vaultRoot, action.path, action.content); | ||
| if (!wasExisting) ctx.addedPaths.push(action.path); | ||
| } |
There was a problem hiding this comment.
add actions can overwrite an existing on-disk file: wasExisting is only used to decide whether to record addedPaths, but writeFile always overwrites. This is especially risky because snapshotForRollback does not snapshot add paths, so an update could destroy an untracked user file with no rollback restore. Consider treating add as a collision when the path already exists (prompt/skip/error), or snapshot and preserve the pre-existing content before overwriting.
There was a problem hiding this comment.
Fixed in d600dab. snapshotForRollback now includes add paths too — copyOptional ENOENTs on the normal (non-existent) case, but captures the pre-existing user file when there is one, so rollback restores it. addedPaths is now populated only when the path was actually absent pre-write (checked via pathExists), so rollback erases only the paths this run newly introduced.
| for (const entry of resolution.copy) { | ||
| const buffer = await fsp.readFile(entry.sourcePath); | ||
| const content = buffer.toString('utf-8'); | ||
| outputs.push({ | ||
| outputPath: entry.outputPath, | ||
| entry, | ||
| content, | ||
| hash: sha256(buffer), | ||
| }); |
There was a problem hiding this comment.
renderNewShard reads copied files as UTF-8 strings (buffer.toString('utf-8')). The install path writes resolution.copy entries as raw buffers, so updates may corrupt non-text/binary assets (images, PDFs, etc.) and also make hashes/content comparisons inconsistent. Consider carrying copy-file content as a Buffer through the plan (or re-reading as Buffer in the executor) and writing with a buffer-safe writer, mirroring install-executor behavior.
There was a problem hiding this comment.
Fixed in d600dab. RenderedFileEntry now carries an optional copyFromSourcePath for copy-origin entries (binary assets, scripts, anything outside Nunjucks). Planner threads it onto add / overwrite / restore_missing actions. Executor's new writeAction prefers fsp.copyFile when that pointer is present, otherwise writes content as UTF-8. Bytes survive round-trip. Regression test asserts sha256 of a binary fixture round-trips identical.
| // Added: files in the new shard that don't exist in state. | ||
| const trackedPaths = new Set(Object.keys(currentState.files)); | ||
| for (const output of newPlan.outputs) { | ||
| if (trackedPaths.has(output.outputPath)) continue; | ||
| actions.push({ | ||
| kind: 'add', | ||
| path: output.outputPath, | ||
| content: output.content, | ||
| renderedHash: output.hash, | ||
| templateKey: toTemplateKey(newTempDir, output.entry.sourcePath), | ||
| ...(output.entry.iterator ? { iteratorKey: output.entry.iterator } : {}), | ||
| }); |
There was a problem hiding this comment.
The "added" pass only checks currentState.files to decide whether a new output is an add. If a user already has an untracked file at output.outputPath (often reported in drift.orphaned, but also possible outside tracked dirs), this will plan an add and the executor will overwrite it silently. Consider treating existing-on-disk paths as collisions (prompt/skip/conflict) and/or incorporating drift.orphaned (and/or an fs.stat check) before emitting add actions.
There was a problem hiding this comment.
Addressed as part of the add-safety fix in d600dab. Rather than promoting existing-on-disk to a user-facing collision prompt (which would be a wizard-level behavior change — install has the same question via ExistingInstallGate), the executor now snapshots the pre-existing file before the add write, so a failed update's rollback restores it. For a successful update where the user had an orphan at the same path, the update quietly adopts the shard's version — matches install-time behavior. Follow-up to surface the collision interactively would be a --confirm-collisions flag; low priority given the snapshot-restore floor.
| }, | ||
| newContent: target.content, | ||
| newContentHash: target.hash, | ||
| templateKey: toTemplateKey('', target.entry.sourcePath), |
There was a problem hiding this comment.
conflictFromDirect sets templateKey via toTemplateKey('', target.entry.sourcePath), which will store an absolute filesystem path in state (because tempDir is empty). That will later break cache lookups that expect a templates/... relative key. Consider using the real newTempDir to compute a relative key here, or setting templateKey to null if it cannot be determined safely.
| templateKey: toTemplateKey('', target.entry.sourcePath), | |
| templateKey: null, |
There was a problem hiding this comment.
Stale — already fixed in commit 21a900b (round 2 cleanup). conflictFromDirect now receives the real newTempDir and produces a cache-relative templateKey matching the other branches. Current code at update-planner.ts:495-540 reflects the fix.
… I/O Cleanup pass after #12 lands. Same behavior, less duplication, faster planning and rollback prep. Deduplication: - Extract `CommandFrame` — dry-run banner + keyboard legend — used by both install and update commands. - Rename `InstallProgress` → `CommandProgress` and delete the identical `UpdateProgress`. One component now. - Extract `summarizeHook` + `useSigintRollback` to `hooks/shared.ts`. Both machines drop their copies. - Move `mapConcurrent` from drift.ts to fs-utils.ts so planner + executor can share it. Perf: - Render the new shard once per update: planner accepts a precomputed `NewFilePlan`, the state machine threads the one rendered at the removed-files check straight through. Previously: two full render passes per update. - `renderNewShard` now runs `render` + `copy` passes in parallel with bounded concurrency. - `planUpdate` runs the per-modified-file merge loop in parallel instead of awaiting each `readFile` + `computeMergeAction` in turn. - `snapshotForRollback` copies snapshot files with bounded concurrency instead of a serial `for` loop. Bug fix: - `keep_as_user` now deletes the entry from `state.files`. Previously the file stayed on disk (correct) but was still tracked as the old managed path (wrong — the user chose "untrack"). Covered by a new integration test. Smaller cleanups: - Conflict actions carry `theirsHash` from the planner so the executor skips the re-read + re-hash on keep_mine/skip. - Write actions derive "newly introduced" from `action.kind` instead of a pre-write `pathExists` stat. - Remove unused `oldRenderContext` field (planner rendered from `newRenderContext` only). - Simplify `NewValuesPrompt` + `RemovedFilesReview`: call the completion callback inline on the threshold-crossing submit instead of a `done` boolean + `useEffect`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fix conflictFromDirect
Second pass after pushback on the first /simplify — several "not worth"
dismissals turned out to be worth doing.
1. `conflictFromDirect` had a dead branch and a real bug. `target.entry
.sourcePath` is typed `string` — never null — so half the `||` was
unreachable. More importantly, the function called
`toTemplateKey('', target.entry.sourcePath)` which silently produced
an absolute path instead of a cache-relative key. If the fallback
ever fired, the next update's `loadOldTemplate` lookup would fail.
Now threaded through `newTempDir`, matching the other branches.
2. Extract `source/core/values-io.ts :: loadValuesYaml` — one helper
for the two shard-values.yaml readers (install's --values prefill
and update's canonical load). Same read → parse → validate-mapping
flow; the only real difference was schema-based key filtering, now
a parameter. Kills ~30 lines of near-duplicate error-handling code.
3. Restructure `PlanUpdateInput` from 11 flat fields into grouped
sub-objects (`vault`, `values`, `newShard`, `removedFileDecisions`).
The newShard cluster (schema + selections + tempDir + renderContext
+ filePlan) always travels together — grouping prevents callers
from accidentally mixing fields from two different shards. 14 test
sites migrated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aimed at the #48 bar. Four real bugs were hiding under the first pass, all caught by a deliberate adversarial audit and the component-test precedent install already set. ## Bugs fixed - **Data loss in migrator** (`core/migrator.ts`) — `rename: a → b` silently overwrote an existing value at `b`. Now refuses the overwrite, warns, and keeps both keys so the user can untangle manually. Regression test in `migrator.test.ts`. - **Concurrent backup-dir collision** (`core/update-executor.ts`) — the ISO timestamp stripped fractional seconds, so two same-second updates allocated the same `.shardmind/backups/update-<ts>/` directory and clobbered each other's rollback snapshots. Timestamp now retains ms and a numeric suffix is probed on EEXIST. Regression in `update-adversarial.test.ts`. - **DiffView CRLF corruption** (`components/DiffView.tsx`) — the view split on `\n` only, leaving `\r` on each line. Ink treated those as carriage returns and corrupted the terminal when rendering Windows- saved user files. Now splits on `/\r?\n/` everywhere to match `differ.ts`. - **keep_as_user left files tracked** (`core/update-executor.ts`) — the "keep my edits (untrack)" decision kept the file in `state.files`; next update still considered it managed. Now drops the entry on apply. Regression in `update.test.ts`. ## Also - SIGINT now always runs the shard tempdir cleanup — cancelling during the prompt/wizard phase used to leak the extracted tarball. - `DiffView`'s choice handler uses a `Set<DiffAction>` allowlist instead of a bare cast, so the disabled "Open in editor" option can never bypass the v0.2 stub. - `useSigintRollback` API cleaned up: drops the redundant `enabled` param (caller folds the condition into `isActive`) and adds a `cleanup` callback that runs on every Ctrl-C. - `conflictFromDirect` now produces a cache-relative `templateKey` (previously passed `''` for tempDir and emitted an absolute path that would break future updates if the fallback ever fired). - `NewValuesPrompt` + `RemovedFilesReview` use a `submittedRef` + per-file `key` to prevent the re-entrant double-submit that the new component tests caught. ## Tests added (62 new) - `tests/unit/migrator-adversarial.test.ts` — 18 tests incl. 4 `fast-check` properties × 200 runs = 800 generative scenarios. Covers prototype-key pollution, non-Error throws, unserializable transforms, BOM + null bytes, cyclic values, pre-release + build- metadata semver, chained renames, idempotence, transitivity. - `tests/unit/update-adversarial.test.ts` — 11 tests covering missing cached templates (→ `conflictFromDirect` fallback), iterator-shrink cleanup, CRLF user files + LF templates, Unicode/emoji filenames, inconsistent drift + state, concurrent `createBackupDir`, rollback idempotency, render determinism (property). - `tests/component/` — 6 new files for `DiffView`, `CommandFrame`, `NewValuesPrompt`, `NewModulesReview`, `RemovedFilesReview`, `UpdateSummary`, matching the 9-file precedent set by install. DiffView alone covers header rendering, context lines, singular/ plural region labels, each DiffAction, disabled-option filtering, and CRLF robustness. - `tests/integration/update.test.ts` — 2 new cases: `--dryRun` does not mutate the vault; corrupted template cache surfaces the file as a full-file conflict via `conflictFromDirect`. 341 → 405 tests passing. ## Docs - `docs/IMPLEMENTATION.md` §3 — update data flow diagram rewritten to match the actual `useUpdateMachine` phase progression (three discrete prompt phases, explicit rollback branch). - `docs/IMPLEMENTATION.md` §4.10 — corrected migration filter rule (`currentVersion < from_version ≤ targetVersion`), documented the rename-never-clobbers invariant and `MIGRATION_INVALID_VERSION`. - `docs/IMPLEMENTATION.md` §4.11 / §4.12 / §4.13 — new module specs for `update-planner.ts`, `update-executor.ts`, `values-io.ts` in the same §4.x style as the existing engine modules. - `docs/IMPLEMENTATION.md` §6.5 — DiffView prop spec updated to reflect `index` / `total` / `result` / `DiffAction` (was out of date) and the CRLF + Set-allowlist invariants. - `docs/IMPLEMENTATION.md` §7 — error handling table adds the six new update + migration error codes. - `docs/ARCHITECTURE.md` §10.5 — update flow covers `--yes` / `--verbose` / `--dry-run`, removed-files decision prompt, snapshot guarantee; links to IMPLEMENTATION §4.11 / §4.12. - `CLAUDE.md` — module table + source tree diagram include the new core modules, components, and `commands/hooks/` directory. - `CHANGELOG.md` — comprehensive Unreleased entry covering every user-visible surface, following the #48 tone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit follow-ups while doing the fresh-eyes pass: - `NewFilePlan.entriesByPath` was populated but never consumed. Drop it and introduce a named `RenderedFileEntry` type so internals that previously indexed `NewFilePlan['outputs'][number]` read cleanly. - `PendingConflict.index` was always `array.indexOf(self)` — dead weight. Removed from the type and the construction site. - `DiffView` now guards `onChoice` with a `firedRef` (same pattern used by `NewValuesPrompt` + `RemovedFilesReview`), and keys the underlying `Select` on `filePath` so each conflict gets a fresh focus context. Prevents Ink re-fires from resolving a subsequent file's conflict with the same keypress. Regression test added. 406 tests passing (was 405). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fresh-eyes pass found four real issues the prior rounds missed.
- **useSigintRollback** re-registered the SIGINT handler on every
render because its deps were inline arrows from the caller. Pins
the callbacks in refs and registers once on mount.
- **rollbackUpdate** now returns a list of per-file failures instead
of void; runUpdate attaches them to the thrown error's message so
the command layer can surface "update failed AND rollback was
partial" — silent swallowing was telling users the vault was back
to pre-update state when it wasn't.
- **Hook path traversal**: a shard manifest declaring
`hooks.post-update: "../../etc/shadow"` could probe arbitrary
filesystem paths via the existence detection. `lookupHook` now
rejects traversal and absolute paths; resolved hook path must live
under the shard's temp directory. Six unit tests lock the guard.
- **UPDATE_CACHE_MISSING** hint now names the exact
`shardmind install <source>` command to recover, and includes the
original parse error.
Also: swept the diff for stopgap-ish phrasing ("v0.1 doesn't X yet",
"v0.2 stub") and rewrote to describe the permanent invariants
instead. Temporal framing in code + tests rots as the roadmap
advances; reserved it to docs sections that are explicitly about
phase-gated decisions.
413 passing (was 406, +7 for hook tests + rollback-failure test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| useSigintRollback({ | ||
| isActive: () => !dryRun && writingRef.current && backupDirRef.current !== null, | ||
| rollback: async () => { | ||
| if (backupDirRef.current) { | ||
| await rollbackUpdate(vaultRoot, backupDirRef.current, addedPathsRef.current); | ||
| } | ||
| }, |
There was a problem hiding this comment.
SIGINT rollback can’t work during the write phase: backupDirRef.current is only set after runUpdate() resolves, so isActive() is false while writes are in progress. Also addedPathsRef is reset but never populated, so even if rollback ran it wouldn’t remove newly-added files. Suggestion: expose backupDir (and addedPaths) from the executor earlier (e.g., callback/event when backup dir is created and when a path is newly added) or move backup allocation/snapshotting into the machine so refs are set before the first write.
There was a problem hiding this comment.
Same SIGINT issue as 3107674252 — addressed by exposing onBackupReady + onFileTouched from the executor so the state machine can populate its refs mid-run instead of waiting for the return value. Commit d600dab.
| return { | ||
| outputPath: entry.outputPath, | ||
| entry, | ||
| content: buffer.toString('utf-8'), |
There was a problem hiding this comment.
renderNewShard() reads copy-entry files as a Buffer but then coerces them to UTF-8 (buffer.toString('utf-8')) and stores them in content: string. The executor subsequently writes content via fsp.writeFile(..., 'utf-8'). This breaks byte-for-byte updates for any non-UTF8 or binary files (and is inconsistent with install, which writes resolution.copy entries as raw buffers). Suggestion: carry copied file contents as Buffer (or a distinct action type for copied assets) and have the executor write buffers without re-encoding; also ensure modified copied files don’t go through the template-based three-way merge path.
| content: buffer.toString('utf-8'), | |
| // Preserve copied assets as raw bytes so updates remain | |
| // byte-for-byte identical for binary and non-UTF-8 files. | |
| content: buffer, |
There was a problem hiding this comment.
Same as 3107674269 — fixed in d600dab. Went with the copyFromSourcePath approach rather than storing Buffers in actions: keeps the merge-engine text pipeline clean (it only cares about rendered strings) while giving the executor a byte-safe path for copy-origin files. Merge/diff never touches copy-origin content anyway, so the string content there is informational-only.
Six real findings from Copilot. Two were stale (already fixed in earlier rounds). One was a doc overpromise. The other four were correctness bugs worth fixing before merge. - **SIGINT rollback unreachable** (`use-update-machine.ts` + `update-executor.ts`). `backupDirRef.current` was only assigned after `runUpdate` resolved, but Ctrl-C fires during the run — so the handler always saw `null` and skipped rollback. Executor now exposes `onBackupReady` (fires after snapshot, before any write) and `onFileTouched` (fires per write with `introduced: boolean`). The machine populates its refs eagerly via these callbacks. Integration test locks the ordering (backup callback fires before any file write). - **Progress bar could overshoot 100%** (`update-executor.ts`). `total` was `writable.length` which excludes keep_mine/skip conflicts, but the loop emitted progress events for them anyway — so `index` could exceed `total`. New `actionEmitsProgress` helper computes the progress total correctly; `actionWrites` retired. - **`add` could silently overwrite an untracked user file with no rollback restore** (`update-executor.ts`). Snapshot pass now includes `add` paths too — if a user has a pre-existing file at the same path (an orphan, or an untracked collision), it's in the backup and rollback restores it. `addedPaths` only tracks paths this run actually introduced, so rollback erases the right set. - **Copy-origin binary assets corrupted by UTF-8 round-trip** (`update-planner.ts` + `update-executor.ts`). `renderNewShard` now carries a `copyFromSourcePath` pointer on copy-origin entries; planner threads it onto `add` / `overwrite` / `restore_missing` actions; executor's new `writeAction` prefers `fsp.copyFile` over `fsp.writeFile(..., 'utf-8')` when that pointer is present. Bytes survive the round trip. Test locks binary-hash equality. - **Stale "atomically" comment** on `writeValuesFile` — clarified that atomicity comes from the snapshot-based rollback, not from the write itself. **Stale findings (already fixed earlier, noted in response):** - `drift.missing` "leave alone" comment mismatch — comment was already gone by round 2. - `conflictFromDirect` absolute `templateKey` — fixed in commit 21a900b (round 2). 415 passing (was 413, +2 tests: binary-bytes preservation, SIGINT- reachability ordering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ts (#54) Three real defects shipped inside the E2E scope because the subprocess surface exposed them and because they're too rough to defer: 1. UPDATE_NO_INSTALL was declared in source/runtime/errors.ts but never thrown — use-update-machine.ts handled the null-state case with a friendly no-install phase variant (exit 0). CHANGELOG claimed the code shipped in PR #52 but it was dead. Throwing the typed error surfaces a code + hint to stdout and exits 1 so scripting / CI can branch. 2. UPDATE_SOURCE_MISMATCH was also declared but never thrown. When state.source is malformed, resolveRef throws REGISTRY_INVALID_REF with a hint that tells the user to fix their CLI input — but during update the ref came from .shardmind/state.json, so that hint is actively misdirecting. Wrap with the typed code + a remediation hint that points at state.json and embeds the malformed value. 3. SHARD_NOT_FOUND / VERSION_NOT_FOUND via resolveRef carry install-path hints ("Check spelling", "Pick an available version or omit @Version") that equally misdirect during update. Rewrite the hints for the update audience while keeping the codes — the user gets the same code they'd see during install but a remediation that makes sense for state-sourced refs. The resolve+rewrite logic is extracted as `resolveRefForUpdate` and the null-state throw as `throwNoInstall`, with a `lookupUpdateTarget` orchestrator that composes both — all exported for unit testing. The useEffect in the hook now just calls `lookupUpdateTarget(vaultRoot)` and the terminal-state rendering in update.tsx drops the no-install phase block entirely. Error rendering path in update.tsx gains exit 1 via the refactor in a sibling commit. 7 new unit tests in tests/unit/update-machine.test.ts pin every throw path and every hint rewrite (including the passthrough cases for REGISTRY_NETWORK where the hint is already context-agnostic) and assert `lookupUpdateTarget` fails with UPDATE_NO_INSTALL on a missing state, UPDATE_SOURCE_MISMATCH on a malformed state.source, and succeeds with a ResolvedShard when resolution works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #12. Milestone 4's update command — the moat the whole project is built around.
Summary
shardmind updatecommand with the full pipeline: fetch → 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 viaDiffView→ 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.migrator.ts,update-planner.ts,update-executor.ts,values-io.ts(shared YAML load for install + update), and thecommands/hooks/shared.tsutility (cross-machinesummarizeHook+useSigintRollback).DiffView,NewValuesPrompt,NewModulesReview,RemovedFilesReview,UpdateSummary, plusCommandFrameandCommandProgress(shared with install —InstallProgresswas renamed).source/runtime/errors.ts:UPDATE_NO_INSTALL,UPDATE_SOURCE_MISMATCH,UPDATE_CACHE_MISSING,UPDATE_WRITE_FAILED,MIGRATION_INVALID_VERSION,MIGRATION_TRANSFORM_FAILED.$EDITORin DiffView) and 24h update-check cache for status + update commands #51 (24h update-check cache shared with the status command).Highlights
source/core/migrator.ts) —applyMigrationstransformsshard-values.yamlacross versions. Filter rulecurrentVersion < from_version ≤ targetVersionmakes re-runs idempotent. Four change types (rename/added/removed/type_changed);renamerefuses to clobber an existing target value (warns and keeps both keys) — prevents a class of silent data loss the adversarial audit surfaced.source/core/update-planner.ts) — pure. Inputs grouped as{ vault, values, newShard, removedFileDecisions }so call sites can't accidentally mix fields from two different shards. EmitsUpdatePlanwith 10 action variants plus apendingConflictsqueue the state machine drives throughDiffView. Per-modified-file merges run in parallel viamapConcurrent(drift.modified, 16, …).theirsHashcaptured at plan time sokeep_mine/skipresolutions don't re-read + re-hash the file.source/core/update-executor.ts) — snapshot-based rollback. Backup directory allocation is collision-safe under same-millisecond concurrent updates (ISO timestamp retains ms, then numeric suffix on EEXIST). Snapshot copies touched files +.shardmind/state.json+ cached manifest/schema/templates in parallel (SNAPSHOT_CONCURRENCY=16). Write pass runs before delete pass so rename-style moves can't clobber the incoming file. Rollback returns per-file failures (RollbackFailure[]) rather than swallowing them — the command layer surfaces "update failed AND rollback was partial" instead of lying about vault state./\r?\n/),Selectwith three active options and one disabled placeholder. Allowlist filter +firedRefguard against double-fire and accidental editor-stub activation.source/core/hook.ts) —lookupHookrejects absolute paths and any..traversal; the resolved hook path must live under the shard's temp directory. Prevents a malicious manifest from probing arbitrary filesystem paths via existence detection.--yes,--verbose,--dry-runflags. Dry-run runs the full pipeline (fetch, migrate, plan, merge) without touching the vault or allocating a backup; the summary reports what would happen.useSigintRollback+summarizeHookshared between install and update. SIGINT always runs the tempdir cleanup — cancelling during the prompt/wizard phase used to leak the extracted tarball.Commits (chronological)
The branch is 13 commits; notable ones:
feat: core/migrator.ts …/core/update-planner.ts/core/update-executor.ts— foundation.refactor: share install+update chrome, cache render pass, parallelize I/O— round 1 cleanup (extractsCommandFrame/CommandProgress/shared.ts).refactor: round-two cleanups — dedup YAML load, group planner input, fix conflictFromDirect— round 2, after review pushback exposed three real issues the first pass dismissed.fix+test: round-three hardening audit (adversarial + property-based)— round 3, four bugs + double-submit guard + 6 component test files + migrator/update adversarial suites.fix+test: round-four /harden audit— round 4, four more bugs (SIGINT handler re-registration, rollback swallowing, hook path traversal, error hint specificity) + hook path-traversal tests + stale-phrasing sweep.Docs
docs/IMPLEMENTATION.md§3 — Update data flow diagram rewritten to the actualuseUpdateMachinephase progression.docs/IMPLEMENTATION.md§4.10 — corrected migration filter rule, added the rename-never-clobbers invariant.docs/IMPLEMENTATION.md§4.11 / §4.12 / §4.13 — new module specs for the planner, executor, andvalues-io.docs/IMPLEMENTATION.md§6.5 — DiffView props updated to match the actual component shape.docs/IMPLEMENTATION.md§7 — error handling table adds the six new codes.docs/ARCHITECTURE.md§10.5 — update flow expanded to cover the new flags and the snapshot/rollback guarantee.CLAUDE.md— module table + source tree include the new files.CHANGELOG.mdUnreleased — comprehensive entry covering every user-visible surface.Test plan
npm run typecheck— strict mode clean; zeroany/@ts-ignore/@ts-nocheckin the diff.npm test— 413/413 passing across 38 test files.npm run build— runtime bundle 7.9 KB (under the 30 KB target);dist/commands/update.jsemitted.tests/integration/update.test.ts: silent overwrite, auto-merge, pending conflict w/ keep_mine, up-to-date, new-required-value detection, removed-file decision, keep_as_user untracks from state, dry-run no-mutation, corrupted-cache conflict fallback.ink-testing-library— caught a re-entrant double-submit bug before ship.🤖 Generated with Claude Code
Harden Audit
Reference PR: #48
Rounds
/simplify: reuse + quality + efficiency cleanups. ExtractedCommandFrame/CommandProgress/shared.ts; cachedNewFilePlan(removed double-render); parallelized modified-merge + snapshot I/O.PlanUpdateInputinto{ vault, values, newShard }, extractedvalues-io.ts, fixedconflictFromDirectdead-branch + wrong-tempdir bug.keep_as_userleft files tracked. Plus a re-entrant double-submit inNewValuesPrompt/RemovedFilesReviewthat the new component tests caught./hardenfresh-eyes pass. Four more: SIGINT handler re-registered every render, rollback failures silently swallowed, hook path traversal probe, vagueUPDATE_CACHE_MISSINGhint. Also swept code comments for stale temporal phrasing.Real bugs found
core/migrator.ts—renamesilently overwrote target; now refuses + warns.core/update-executor.ts :: createBackupDir— same-second timestamp collision; now ms + numeric-suffix probe.components/DiffView.tsx—\rleaked through\n-only split and corrupted the terminal; splits on/\r?\n/.core/update-executor.ts :: applyWriteAction—keep_as_userkept the entry instate.files(the user chose "untrack" but the engine still considered it managed); now deletes.commands/hooks/shared.ts :: useSigintRollback— deps were inline arrows, teardown+re-register on every render; refs pin the callbacks.core/update-executor.ts :: runUpdate— rollback errors swallowed via.catch(() => {}); nowrollbackUpdatereturns a failure list which is attached to the thrown error.core/hook.ts :: lookupHook— path traversal / absolute paths allowed a shard to probe arbitrary filesystem paths; now sandboxed to tempDir.core/update-planner.ts :: conflictFromDirect— passed''as tempDir, producing absolutetemplateKey; fixed to threadnewTempDirthrough.Tests added
ink-testing-library— DiffView, CommandFrame, NewValuesPrompt, NewModulesReview, RemovedFilesReview, UpdateSummary.conflictFromDirectfallback via wiped cache,keep_as_useruntracks from state.Deferrals
$EDITORintegration for DiffView conflict resolution (disabled placeholder shipped).