11 KiB
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
suaveplan-install upgrade <plugin> [--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/@<scope>/<pkg>/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:
- Stage — every
write/patch-*op writes its content to<targetDir>/.tmp-<txn>/<path>. Deletions are recorded as "move-aside to<targetDir>/.tmp-<txn>/.deleted/<path>" — files are moved, not removed, so rollback can restore them. No live files have been mutated yet. - Migrate —
MigrationScripts run against the staged tree (theirMigrationContext.fsis rooted at the tmp directory). They can mutate staged files or emit additionalFileOps, which are appended to the plan and re-staged into the same tmp directory. - 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 forwrite, children before parents fordelete). - 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: <original apply error> })
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:
- The pre-upgrade
hashTree— recorded before staging so the upgrade has a content-addressed manifest of what existed. - The move-aside journal — every file the diff intends to delete or
overwrite is moved to
<tmp>/.deleted/<path>(rename is O(1) on the same filesystem). Disk usage during upgrade equalssize(diff.changed ∪ diff.removed)rather thansize(entire tree). - The new staged content — every
writelands in<tmp>/<path>and isrenamed 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
// 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
"<from>-><to>"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_FAILEDwith{ 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 <version> |
Overrides the version detected from the state file. Useful when the state file is missing or stale. |
--to <version> |
Overrides the version resolved from node_modules. Useful for pin-rollforward simulations. |
--answers <file> |
Same as add — JSON file supplying answers for any prompts the plan emits. |
Idempotency
Two notions, both held:
- Same-version upgrade is a no-op.
installed == targetexits withINSTALLER.UPGRADE_SAME_VERSIONwithout staging anything. - 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
{
"$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 onepatch-jsonop againstdata.json.upgrade-fixture-plugin@2.0.0— dropsb.txt, renamesa.txt→a-renamed.txt, addsc.txt, changes thepatch-jsonmerge, and ships one migration script1.0.0->2.0.0that rewritesdata.jsonschema.
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
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-04makes the same trade-off foradd. Consistency withaddwins. - BLAKE3 over SHA-256.
@suaveplan/crypto-hashships 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.