# Design: add-installer-upgrade This document captures the non-normative design for the deferred upgrade flow. Normative requirements live in `specs/installer/spec.md`. ## Flow diagram ```text suaveplan-install upgrade [--from X] [--to Y] [--dry-run] [--force] │ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ Host.upgrade(plugin, options) │ └────────────────────────────────────────────────────────────────────────┘ │ ├─► loadState(fs, cwd) │ state-file: .suaveplan/installer-state.json │ missing entry → INSTALLER.UPGRADE_NOT_INSTALLED │ ├─► resolveTargetVersion(plugin, cwd) │ reads node_modules/@//package.json │ missing → INSTALLER.UPGRADE_VERSION_NOT_FOUND │ ├─► if installed === target && !force │ → INSTALLER.UPGRADE_SAME_VERSION (idempotent no-op) │ ├─► preflight │ ├─ detectLockfile() → UPGRADE_LOCKFILE_MISSING │ └─ detectDirtyTree() → UPGRADE_DIRTY_TREE (unless --allow-dirty) │ ├─► hashTree(targetDir) → currentHashes (rollback snapshot key) │ ├─► plan(answers, ctx) at fromVersion → fromOps │ plan(answers, ctx) at toVersion → toOps │ ├─► computeDiff(fromOps, toOps) │ { added, removed, changed, unchanged } │ ├─► materialiseDiff(diff) → FileOp[] │ additions → write │ removals → delete │ changes → write (overwrite) | patch-json (merge) │ ├─► Host.applyOps(ops) ◄── EXISTING TRANSACTIONAL PATH │ staged-tmp + atomic rename (INSTALLER-04) │ ├─► runMigrations(selected, ctx) │ throws → rollback transaction → UPGRADE_MIGRATION_FAILED │ ├─► writeState({ pluginId, version: to, installedAt, fileHashes }) │ atomic write → fsync → rename │ └─► OTel: span "installer.upgrade" closes with attributes plugin.id, plugin.version.from, plugin.version.to, dry_run, files.added, files.removed, files.changed ``` ## Atomicity model The upgrade is **one** transaction, not two. The diff-apply step and the migration-run step share the same staged-tmp directory created by `Host.applyOps`. The window is: 1. **Stage** — every `write`/`patch-*` op writes its content to `/.tmp-/`. Deletions are recorded as "move-aside to `/.tmp-/.deleted/`" — files are **moved**, not removed, so rollback can restore them. No live files have been mutated yet. 2. **Migrate** — `MigrationScript`s run against the staged tree (their `MigrationContext.fs` is rooted at the tmp directory). They can mutate staged files or emit additional `FileOp`s, which are appended to the plan and re-staged into the same tmp directory. 3. **Commit** — once every stage step has succeeded, the host issues atomic `rename(tmp, live)` for every staged path **in dependency order** (parent dirs before children for `write`, children before parents for `delete`). 4. **Persist state** — only after every rename commits does the host write the new `installer-state.json` (itself via the same write-fsync-rename dance scoped to one file). Any failure in steps 1–3 walks the journal in reverse: staged tmp files are unlinked; moved-aside files are renamed back; the state-file is untouched. The user's tree is bit-identical to the pre-upgrade state. A rollback failure (e.g. fs returns EIO on the restore rename) surfaces as `InstallerError(INSTALLER.ROLLBACK_FAILED, { cause: })` exactly as `INSTALLER-04` already prescribes for the `add` flow. ## Snapshot / rollback strategy The "snapshot" is not a copy of the tree — that would be linear in target size and unacceptable for a skills bundle. Instead, the snapshot is: 1. **The pre-upgrade `hashTree`** — recorded before staging so the upgrade has a content-addressed manifest of what existed. 2. **The move-aside journal** — every file the diff intends to delete or overwrite is moved to `/.deleted/` (rename is O(1) on the same filesystem). Disk usage during upgrade equals `size(diff.changed ∪ diff.removed)` rather than `size(entire tree)`. 3. **The new staged content** — every `write` lands in `/` and is `rename`d in only on commit. Rollback walks the journal in reverse: rename moved-aside files back to their live paths, unlink staged files, rmdir the tmp directory. ## Migration script contract ```ts // In a plugin: src/upgrade.ts import type { MigrationTable, MigrationScript } from "@suaveplan/installer/migrations"; const v1_to_v2: MigrationScript = async (ctx) => { // ctx.fs is rooted at the staged tmp directory. // ctx.signal honours --abort. // ctx.logger is the host's structured logger. // ctx.from = "1.0.0", ctx.to = "2.0.0" const old = await ctx.fs.readFile("skills/legacy.json"); await ctx.fs.writeFile("skills/legacy.yaml", convert(old)); await ctx.fs.delete("skills/legacy.json"); }; export const upgradeTable: MigrationTable = { "1.0.0->2.0.0": v1_to_v2, }; ``` Rules: - Keys are `"->"` semver strings. The selector finds the shortest path of contiguous edges from the installed version to the target. - Scripts MUST be pure with respect to outside-the-tmp state. Network access is permitted (e.g. fetching upstream renames) but discouraged. - Scripts MAY throw. A throw rolls the transaction back and surfaces `INSTALLER.UPGRADE_MIGRATION_FAILED` with `{ cause }`. - Scripts MAY return additional `FileOp[]` that the host appends to the plan and re-stages before commit. ## CLI behaviour | Flag | Effect | |---|---| | `--dry-run` | Computes diff + migration list, prints the plan, exits before staging. | | `--force` | Collapses conflict policies to `overwrite` AND bypasses the dirty-tree guard. Equivalent to `--allow-dirty` + per-file overwrite. | | `--allow-dirty` | Bypasses dirty-tree guard but does NOT change conflict policies. Used when the operator knows their edits are reconcilable. | | `--no-interactive` | Any prompt op fails with `INSTALLER.APPLY_CONFLICT` (existing semantics). | | `--from ` | Overrides the version detected from the state file. Useful when the state file is missing or stale. | | `--to ` | Overrides the version resolved from `node_modules`. Useful for pin-rollforward simulations. | | `--answers ` | Same as `add` — JSON file supplying answers for any prompts the plan emits. | ## Idempotency Two notions, both held: 1. **Same-version upgrade is a no-op.** `installed == target` exits with `INSTALLER.UPGRADE_SAME_VERSION` without staging anything. 2. **Successful upgrade is replayable.** If the user runs the same upgrade again, the state file already reflects `target`, so step 1 fires. There is no way to "double-apply" a migration script. ## State file shape ```jsonc { "$schema": "installer-state/v1", "plugins": { "agent-skills": { "version": "0.7.0", "installedAt": "2026-05-17T12:34:56Z", "targetDir": ".suaveplan/skills", "fileHashes": { "skills/crypto/SKILL.md": "blake3:...", "skills/events/SKILL.md": "blake3:..." } } } } ``` The schema version (`$schema`) is bumped any time the shape changes; the loader rejects unknown versions with `INSTALLER.UPGRADE_STATE_INVALID`. ## Coverage strategy Two fixture plugins live under `src/testing/fixtures/`: - `upgrade-fixture-plugin@1.0.0` — emits two files (`a.txt`, `b.txt`) and one `patch-json` op against `data.json`. - `upgrade-fixture-plugin@2.0.0` — drops `b.txt`, renames `a.txt` → `a-renamed.txt`, adds `c.txt`, changes the `patch-json` merge, and ships one migration script `1.0.0->2.0.0` that rewrites `data.json` schema. Together they exercise every branch in `computeDiff`, every conflict policy in the diff materialiser, both the migration-success and migration-throw paths, and both the same-version-noop and version-not-found paths. 100% istanbul falls out naturally. ## File layout ```text packages/tooling/installer/src/ state/ state.types.ts state.schemas.ts state-file.ts state-file.md state-file.spec-conformance.test.ts hash-tree.ts hash-tree.md host/ upgrade/ upgrade.ts upgrade.md upgrade.spec-conformance.test.ts preflight.ts preflight.md preflight.spec-conformance.test.ts dirty-tree.spec-conformance.test.ts diff.ts diff.md apply-diff.spec-conformance.test.ts migrations.ts migrations.types.ts migrations.md migrations.spec-conformance.test.ts index.ts testing/ upgrade-plugin.ts upgrade-plugin.md fixtures/ upgrade-fixture-plugin.ts upgrade-fixture-plugin.md ``` ## Decisions and trade-offs - **Move-aside vs full snapshot.** Move-aside wastes one rename per affected file but is O(1) per file. A full snapshot would be safer against concurrent writes but the installer already requires the user to not be editing files concurrently — `INSTALLER-04` makes the same trade-off for `add`. Consistency with `add` wins. - **BLAKE3 over SHA-256.** `@suaveplan/crypto-hash` ships both. BLAKE3 is ~3× faster for the small-file case (skill files average <4 KB) and parallelisable. No security requirement (this is a change-detection hash, not a tamper-proof signature). - **Migration scripts run AFTER diff stage, not before.** Running them before would mean diffing against a tree the migration script has already mutated, which makes the "did the user modify this file?" question ambiguous. The order is: diff first (against the unmodified staged tree), then migrate. - **No network resolution.** Resolving "latest" from npm would couple the installer to a registry shape, force network access in CI, and conflict with the project's air-gapped use cases. The target version is whatever resolves from `node_modules`; the operator bumps the dep with their package manager first.