Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
# 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 <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:
1. **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.
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: <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:
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 `<tmp>/.deleted/<path>` (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 `<tmp>/<path>` 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 `"<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_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 <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:
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.

View File

@@ -0,0 +1,145 @@
# OpenSpec Change: add-installer-upgrade
- **Change ID**: add-installer-upgrade
- **Package**: `@suaveplan/installer`
- **Status**: PROPOSED
- **Date**: 2026-05-17
- **Design**: ./design.md
- **Spec delta**: ./specs/installer/spec.md
## Why
`openspec/archive/2026-04-28-installer-port/proposal.md` §"Out of scope" deferred
the upgrade flow:
> Plugin upgrade flow (`upgrade <plugin>` to bring an existing install up to
> current bundle version) is a follow-up — initial scope is install + reinstall
> (idempotent re-run with conflict policy).
The installer therefore ships `add <plugin>` and `list` but has no answer for a
user who installed `@suaveplan/install-agent-skills@0.5.0` six months ago and
wants the changes that landed in `0.7.0`. Today their only options are
`add agent-skills --force` (which overwrites local edits without diffing) or a
manual `rm -rf` (which destroys their in-flight skill edits). Both are unsafe.
The `add` path is also unable to run plugin-authored migration scripts, so any
breaking layout change in a plugin bundle (e.g. `skills/<id>/SKILL.md` →
`skills/<id>/<id>.md`) silently leaves stale files behind that the user has to
hand-prune.
## What Changes
Add a third top-level installer command, `suaveplan-install upgrade <plugin>`,
that resolves the **installed** plugin version from the on-disk state file,
resolves the **target** version from `node_modules`, computes the file-level
diff between the two bundles (added / removed / changed), and applies it
transactionally with the same staged-tmp + rollback strategy `apply` already
uses for `add`. Pre-flight gates protect against in-flight edits (dirty target
tree) and missing lockfiles. Plugins MAY ship an optional `upgrade.ts`
migration script per from-version → to-version pair; the host invokes them in
order inside the same transaction.
### Surface added
- `suaveplan-install upgrade <plugin>` CLI verb with flags
`--dry-run`, `--force`, `--no-interactive`, `--answers <file>`,
`--from <version>` (override detected), `--to <version>` (target; defaults
to whatever resolves from `node_modules`), `--allow-dirty` (skip dirty-tree
guard — destructive, opt-in only).
- `Host.upgrade(plugin, options)` public entry point alongside `Host.run`.
- A persisted state file (`.suaveplan/installer-state.json`) capturing
`{ pluginId, version, installedAt, fileHashes }` per installed plugin so
diff and dirty-tree detection are deterministic on subsequent upgrades.
- A `MigrationContext` and `MigrationScript` contract under
`@suaveplan/installer/migrations` for plugin-authored scripts.
- 7 new `INSTALLER.*` codes: `UPGRADE_NOT_INSTALLED`,
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`,
`UPGRADE_LOCKFILE_MISSING`.
### Behaviour guarantees
- Re-running `upgrade <plugin>` when installed == target is a no-op exiting
with `INSTALLER.UPGRADE_SAME_VERSION` (treated as success under `--dry-run`).
- Failure at any point (diff compute, migration script throw, atomic rename
error) rolls the target tree back to the pre-upgrade snapshot. Partial
state is never persisted.
- The state file is updated atomically (`write → fsync → rename`) only after
the transaction commits.
## Source mapping
| Source / archive reference | What it tells us |
|---|---|
| `openspec/archive/2026-04-28-installer-port/proposal.md` §"Out of scope" bullet 2 | The deferred upgrade flow is the scope of this change. |
| `openspec/archive/2026-04-28-installer-port/proposal.md` §"Architecture → Lifecycle" | Reuse the existing `detect → prompts → plan → apply → postInstall` pipeline; upgrade adds a diff-and-migrate stage between `plan` and `apply`. |
| `openspec/specs/installer/spec.md` `INSTALLER-04` (transactional apply) | Upgrade MUST reuse the same staged-tmp + rollback machinery — no parallel transaction model. |
| `openspec/specs/installer/spec.md` `INSTALLER-05` (conflict policy) | Per-file conflict policy already exists; upgrade extends it with a "user-modified-since-install" case detected via the state-file hashes. |
| `openspec/specs/installer/spec.md` `INSTALLER-09` (frozen registry) | New codes MUST extend `defineCodes("INSTALLER", [...])` — no parallel registry. |
| `packages/tooling/installer/src/bin/suaveplan-install.ts` | Existing CLI shape: `cli().command("add <plugin>")…`. The new verb attaches identically: `.command("upgrade <plugin>")`. |
| `packages/tooling/installer/src/host/host.ts` | `Host` class is the orchestration seam; `Host.upgrade` slots in alongside `Host.run`. |
| `packages/tooling/installer/src/host/apply/apply.ts` | Staged-tmp transaction implementation is reused as-is; upgrade composes it. |
| `packages/tooling/installer/src/manifest/discover.ts` | Version resolution from `node_modules` already exists for `add`; upgrade reuses `findPlugin(cwd, pluginId)`. |
| `packages/tooling/installer/src/error-codes.ts` | New codes APPEND to the existing tuple; the registry stays frozen. |
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/installer` to enumerate. Current
dependents:
- `@suaveplan/install-agent-skills` — [x] tests run / [x] docs reviewed /
[ ] no action needed. The skills plugin is the canonical upgrade target;
its existing `runPlugin` harness gains an `upgradePlugin` sibling so its
conformance tests cover both flows. The plugin itself receives a tiny
optional `upgrade.ts` exporting an empty migration table (no breaking
layout changes yet); this proves the migration contract loads even when
the table is empty.
No other dependents currently consume `@suaveplan/installer`.
## Out of scope
- **Downgrade.** `upgrade` only moves forward. A separate
`add-installer-downgrade` change can extend the diff engine in reverse;
the migration contract is intentionally directional (`from`/`to` versions)
so downgrade is a different scope.
- **Cross-plugin coordination.** Upgrading `install-agent-skills` does not
trigger an upgrade of a hypothetical `install-web-server`. Each plugin is
upgraded independently; `peerInstallers` field warnings are a future
packaging change.
- **Background / scheduled upgrade daemon.** Upgrade is operator-initiated
via the CLI; there is no auto-update behaviour.
- **Network resolution.** The "target version" is whatever resolves from
`node_modules`; the installer never talks to npm directly. Bumping the
installed package version remains the user's package-manager step.
## Verification
Every requirement in `specs/installer/spec.md` carries a `**Verified by:**`
line pointing to a conformance test in `src/host/upgrade/` or
`src/state/`. The full set:
- `INSTALLER-UPGRADE-01` Lifecycle order → `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"`
- `INSTALLER-UPGRADE-02` Idempotent same-version no-op → `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"`
- `INSTALLER-UPGRADE-03` Dirty-tree guard → `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"`
- `INSTALLER-UPGRADE-04` Lockfile pre-flight → `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"`
- `INSTALLER-UPGRADE-05` Transactional diff application → `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"`
- `INSTALLER-UPGRADE-06` Migration script contract → `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"`
- `INSTALLER-UPGRADE-07` Migration failure rollback → `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"`
- `INSTALLER-UPGRADE-08` State-file integrity → `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"`
- `INSTALLER-UPGRADE-09` Error-code registry namespacing → `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"`
- `INSTALLER-UPGRADE-10` Version-not-found surface → `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"`
- `INSTALLER-UPGRADE-11` Not-installed surface → `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"`
- `INSTALLER-UPGRADE-12` CLI flag wiring → `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`
## Acceptance criteria
- `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
- `bunx turbo test:coverage --filter=@suaveplan/installer` 100% istanbul on
every new module under `src/host/upgrade/`, `src/state/`, and the
upgrade-specific additions to `src/bin/suaveplan-install.ts`.
- Sibling `.md` for every new implementation `.ts` per `openspec/AGENTS.md` §4.
- `openspec validate add-installer-upgrade --strict` green.
- Spec delta folded into `openspec/specs/installer/spec.md` on archive.

View File

@@ -0,0 +1,278 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: installer (upgrade flow)
**Package**: `@suaveplan/installer`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> Test files are planned paths inside `packages/tooling/installer/`.
## ADDED Requirements
### Requirement: Upgrade lifecycle order
`Host.upgrade(plugin, options)` SHALL execute upgrade stages in the strict
order `loadState → resolveTarget → preflight → hashTree → planFrom → planTo →
diff → materialise → applyOps → runMigrations → commitState`. Optional stages
(`detect`, `prompts`, `postInstall` from the install lifecycle) SHALL still be
honoured before `loadState` and after `commitState` respectively, preserving
plugin composability. Each stage SHALL be wrapped in an OpenTelemetry span
named `installer.upgrade.<stage>` whose parent span `installer.upgrade`
carries attributes `plugin.id`, `plugin.version.from`, `plugin.version.to`,
and `dry_run`.
#### Scenario: pipeline executes in the documented order
- **WHEN** `Host.upgrade(fixturePlugin, { from: "1.0.0", to: "2.0.0" })` runs
against an installed `1.0.0` fixture and a `2.0.0` bundle in `node_modules`
- **THEN** the OTel exporter records spans in the order `loadState`,
`resolveTarget`, `preflight`, `hashTree`, `planFrom`, `planTo`, `diff`,
`materialise`, `applyOps`, `runMigrations`, `commitState`, all parented to
one `installer.upgrade` span carrying `plugin.id`, `plugin.version.from`,
`plugin.version.to`, and `dry_run`.
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"`
---
### Requirement: Same-version upgrade is idempotent
`Host.upgrade` SHALL short-circuit when the installed version recorded in
the state file equals the target version resolved from `node_modules`, and
surface `InstallerError(INSTALLER.UPGRADE_SAME_VERSION)`. Callers MAY treat
this code as a success indicator. Re-running the same upgrade after a
successful commit SHALL therefore always exit via this short-circuit. The
short-circuit SHALL NOT fire when `--force` was supplied.
#### Scenario: re-running upgrade after success exits with UPGRADE_SAME_VERSION
- **WHEN** `Host.upgrade` is invoked twice in succession against the same
fixture plugin with no `--force` flag
- **THEN** the first invocation MUST commit and write the state file, and
the second invocation MUST throw `InstallerError` with code
`INSTALLER.UPGRADE_SAME_VERSION` and MUST NOT stage any file
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"`
---
### Requirement: Dirty-tree pre-flight guard
Before staging any file op, `Host.upgrade` SHALL hash every path recorded in
the state file's `fileHashes` and compare to the recorded value. Any
mismatched hash SHALL surface as
`InstallerError(INSTALLER.UPGRADE_DIRTY_TREE, { context: { paths: string[] } })`
unless `--allow-dirty` or `--force` was supplied. The error context SHALL
enumerate every divergent path so the operator can reconcile manually.
#### Scenario: divergent on-disk hash blocks the upgrade
- **GIVEN** a state file recording `skills/crypto/SKILL.md` at hash `H1`
- **WHEN** the file on disk hashes to `H2` and `Host.upgrade` runs without
`--allow-dirty` or `--force`
- **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_DIRTY_TREE` is
thrown whose `context.paths` includes `skills/crypto/SKILL.md`
**Verified by:** `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"`
---
### Requirement: Lockfile presence is required
`Host.upgrade` SHALL refuse to run unless at least one of
`bun.lockb`, `bun.lock`, `package-lock.json`, `pnpm-lock.yaml`, or
`yarn.lock` is present in `cwd`. Missing lockfile SHALL surface as
`InstallerError(INSTALLER.UPGRADE_LOCKFILE_MISSING)`. This gate exists to
prevent upgrades against a tree whose dependency graph is unpinned, where
the resolved target version could shift between runs.
#### Scenario: missing lockfile blocks the upgrade
- **WHEN** `Host.upgrade` runs in a `cwd` with no lockfile of any supported
format
- **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_LOCKFILE_MISSING`
is thrown before any file is hashed
**Verified by:** `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"`
---
### Requirement: Transactional diff application with rollback
`Host.upgrade` SHALL materialise the computed diff (`added`, `removed`,
`changed`) into the existing `FileOp` union and feed it through
`Host.applyOps`, reusing the staged-tmp + atomic-rename transaction
described in the canonical `INSTALLER-04` requirement. Removed files SHALL
be moved aside into `<tmp>/.deleted/` rather than unlinked, so rollback can
restore them. Any failure during staging or commit SHALL trigger a
reverse-order rollback that unlinks staged files and renames the
moved-aside files back into place. Rollback failures SHALL surface as
`InstallerError(INSTALLER.ROLLBACK_FAILED, { cause })`.
#### Scenario: rename failure rolls the tree back
- **GIVEN** a fixture plugin whose final `rename` of `a-renamed.txt` is
injected to throw EIO
- **WHEN** `Host.upgrade` runs against an installed `1.0.0` tree containing
`a.txt`
- **THEN** `a.txt` exists at its original content after the throw and
`a-renamed.txt` does not exist, and the throw surfaces as `InstallerError`
with code `INSTALLER.APPLY_FAILED` (or `INSTALLER.ROLLBACK_FAILED` if the
restore also fails) carrying the EIO as `cause`
**Verified by:** `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"`
---
### Requirement: Migration scripts run in semver order
`Host.upgrade` SHALL, when a plugin ships an `upgrade.ts` exporting a
`MigrationTable`, load the table, select the shortest contiguous chain
of edges from `from` to `to`, and execute each `MigrationScript` in order
inside the same transaction as the diff apply. Each script SHALL receive a
`MigrationContext` carrying the same `fs` (rooted at the staged tmp dir),
the same `AbortSignal`, the host `logger`, and the `from`/`to` semver
strings for the current edge.
#### Scenario: chained migrations execute from-to-target in order
- **GIVEN** a plugin with edges `1.0.0->1.1.0` and `1.1.0->2.0.0`
- **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0`
- **THEN** both scripts MUST run, the `1.0.0->1.1.0` script MUST run
first, both MUST receive an `fs` rooted at the same staged tmp
directory, and both MUST observe the same `AbortSignal` instance
**Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"`
---
### Requirement: Migration failure rolls back the transaction
A `MigrationScript` throw SHALL roll back every staged op (including any
ops emitted by earlier migration scripts in the same chain) and SHALL
surface as `InstallerError(INSTALLER.UPGRADE_MIGRATION_FAILED, { cause })`.
The state file SHALL NOT be written when a migration throws. Re-running
the upgrade after a migration failure SHALL therefore see the original
installed version in the state file and start the chain from the
beginning.
#### Scenario: throwing migration leaves tree at pre-upgrade state
- **GIVEN** a plugin whose `1.0.0->2.0.0` migration throws on invocation
- **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0`
- **THEN** the throw surfaces as `InstallerError` with code
`INSTALLER.UPGRADE_MIGRATION_FAILED`, the live tree is bit-identical to
its pre-upgrade content, and the state file still records version
`1.0.0`
**Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"`
---
### Requirement: State file is written atomically on commit only
`Host.upgrade` SHALL persist the new `installer-state.json` entry only
after every diff op and migration script has committed successfully. The
write SHALL use the write-fsync-rename pattern: serialise to
`<cwd>/.suaveplan/installer-state.json.tmp-<txn>`, `fsync` the descriptor,
then `rename` over the live path. Reads SHALL Zod-validate the loaded
state; validation failures SHALL surface as
`InstallerError(INSTALLER.UPGRADE_STATE_INVALID, { cause })`.
#### Scenario: state file appears only after successful commit
- **WHEN** `Host.upgrade` aborts mid-apply (any failure)
- **THEN** the file at `.suaveplan/installer-state.json` is unchanged from
its pre-upgrade content (either still recording the old version or
still absent), and no `.tmp-*` sibling remains on disk
**Verified by:** `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"`
---
### Requirement: Upgrade error codes are namespaced and frozen
The `INSTALLER_CODES` registry SHALL include `UPGRADE_NOT_INSTALLED`,
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`, and
`UPGRADE_LOCKFILE_MISSING` in addition to the existing nine codes. The
registry SHALL remain frozen (`Object.isFrozen(INSTALLER_CODES) === true`).
Every new value SHALL start with `INSTALLER.`. `InstallerError` SHALL
remain the only error class thrown by the upgrade path.
#### Scenario: registry contains the upgrade codes and stays frozen
- **WHEN** the registry is inspected at module-load time
- **THEN** `INSTALLER_CODES.UPGRADE_NOT_INSTALLED` equals
`"INSTALLER.UPGRADE_NOT_INSTALLED"`, every other upgrade code is
present with the same `INSTALLER.` prefix, and
`Object.isFrozen(INSTALLER_CODES)` returns `true`
**Verified by:** `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"`
---
### Requirement: Missing target version surfaces a typed error
`Host.upgrade` SHALL surface
`InstallerError(INSTALLER.UPGRADE_VERSION_NOT_FOUND, { context: { pluginId, requested } })`
when the requested target version (whether supplied via `--to` or resolved
from `node_modules`) does not exist.
The error SHALL be thrown before any pre-flight check runs so callers can
distinguish "plugin not installed at target" from "plugin tree dirty".
#### Scenario: requesting an unresolvable target version fails early
- **GIVEN** an installed plugin at `1.0.0` and `node_modules` containing
only `1.0.0`
- **WHEN** `Host.upgrade(plugin, { to: "9.9.9" })` runs
- **THEN** an `InstallerError` with code
`INSTALLER.UPGRADE_VERSION_NOT_FOUND` MUST be thrown and the
`context.requested` field MUST equal `"9.9.9"`
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"`
---
### Requirement: Upgrading an uninstalled plugin surfaces a typed error
`Host.upgrade` SHALL surface
`InstallerError(INSTALLER.UPGRADE_NOT_INSTALLED, { context: { pluginId } })`
when no entry for the requested `pluginId` exists in the state file.
The error SHALL be thrown before any version resolution runs so the
operator gets the most specific guidance ("run `add` first") without
network or filesystem cost.
#### Scenario: upgrade against empty state file fails fast
- **GIVEN** an empty `.suaveplan/installer-state.json` (no `plugins` entry
for the requested id)
- **WHEN** `Host.upgrade(plugin)` runs
- **THEN** an `InstallerError` with code
`INSTALLER.UPGRADE_NOT_INSTALLED` MUST be thrown before
`resolveTargetVersion` is called
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"`
---
### Requirement: CLI verb forwards every upgrade flag
`suaveplan-install upgrade <plugin>` SHALL parse and forward the flags
`--dry-run`, `--force`, `--no-interactive`, `--answers <file>`,
`--from <version>`, `--to <version>`, and `--allow-dirty` to
`Host.upgrade`. The verb SHALL share the same `InstallerContext`
construction path as `add`, so the underlying `fs`, `logger`, `cli`,
`templates`, and `signal` are identical. `--dry-run` SHALL print the
computed diff (counts of added/removed/changed plus the migration chain)
and exit with code 0 before any staging occurs.
#### Scenario: each upgrade flag reaches Host.upgrade
- **WHEN** `main(["upgrade", "agent-skills", "--dry-run", "--force", "--allow-dirty", "--from", "1.0.0", "--to", "2.0.0", "--answers", "a.json"])`
runs with a recording `Host.upgrade` stub
- **THEN** the stub records one call whose options object contains
`{ dryRun: true, force: true, allowDirty: true, from: "1.0.0", to: "2.0.0", answers: <parsed JSON> }`
**Verified by:** `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`

View File

@@ -0,0 +1,134 @@
# Tasks: add-installer-upgrade
## 1. Error codes
- [ ] 1.1 Append `UPGRADE_NOT_INSTALLED`, `UPGRADE_VERSION_NOT_FOUND`,
`UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`, `UPGRADE_STATE_INVALID`,
`UPGRADE_MIGRATION_FAILED`, `UPGRADE_LOCKFILE_MISSING` to the
`defineCodes("INSTALLER", [...])` tuple in
`packages/tooling/installer/src/error-codes.ts`.
- [ ] 1.2 Extend `src/error-codes.spec-conformance.test.ts` with an
`INSTALLER-UPGRADE-09` block asserting the codes are present, frozen,
and prefixed `INSTALLER.`.
## 2. State file (`src/state/`)
- [ ] 2.1 `src/state/state.types.ts` — `InstallerState`, `InstalledPlugin`,
`FileHash` types.
- [ ] 2.2 `src/state/state.schemas.ts` — Zod schemas for boundary validation.
- [ ] 2.3 `src/state/state-file.ts` — `loadState(fs, cwd)`,
`writeState(fs, cwd, state)` (atomic write → fsync → rename),
`updatePlugin(state, entry)`. Failure → `INSTALLER.UPGRADE_STATE_INVALID`.
- [ ] 2.4 `src/state/hash-tree.ts` — `hashTree(fs, dir)` → `Record<path, hex>`
using BLAKE3 via `@suaveplan/crypto-hash`.
- [ ] 2.5 `src/state/state-file.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-08`.
- [ ] 2.6 `.md` siblings for every implementation file.
## 3. Pre-flight checks (`src/host/upgrade/preflight.ts`)
- [ ] 3.1 `detectLockfile(fs, cwd)` returning `"bun" | "npm" | "pnpm" | "yarn"`
or throwing `INSTALLER.UPGRADE_LOCKFILE_MISSING`.
- [ ] 3.2 `detectDirtyTree(state, currentHashes)` returning the list of
user-modified paths. Throws `INSTALLER.UPGRADE_DIRTY_TREE` unless
`--allow-dirty` or `--force` was supplied.
- [ ] 3.3 `src/host/upgrade/preflight.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-04`.
- [ ] 3.4 `src/host/upgrade/dirty-tree.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-03`.
## 4. Diff engine (`src/host/upgrade/diff.ts`)
- [ ] 4.1 `computeDiff(fromOps, toOps)` returning
`{ added: FileOp[], removed: FileOp[], changed: FileOp[], unchanged: FileOp[] }`.
- [ ] 4.2 `materialiseDiff(diff, options)` translating the diff into the
existing `FileOp` union: removals → `{ kind: "delete" }`, additions →
`{ kind: "write" }`, changes → `{ kind: "write", conflict: "overwrite" }`
(or `merge` for `patch-json` ops).
- [ ] 4.3 Sibling `diff.md` covering algorithm and tie-breaking rules.
## 5. Migration runner (`src/host/upgrade/migrations.ts`)
- [ ] 5.1 `MigrationScript`, `MigrationContext`, `MigrationTable` types in
`src/host/upgrade/migrations.types.ts`.
- [ ] 5.2 `loadMigrationTable(plugin)` dynamic-imports the plugin's
`./upgrade.js` if declared in the manifest; absent table → no-op.
- [ ] 5.3 `selectMigrations(table, from, to)` returns the ordered semver path.
- [ ] 5.4 `runMigrations(selected, ctx)` runs each script inside an
`AbortSignal`-propagating sandbox. Throw → `INSTALLER.UPGRADE_MIGRATION_FAILED`.
- [ ] 5.5 `src/host/upgrade/migrations.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-06`, `INSTALLER-UPGRADE-07`.
## 6. Upgrade orchestration (`src/host/upgrade/upgrade.ts`)
- [ ] 6.1 `Host.upgrade(plugin, options)` method:
1. Load state-file entry; throw `UPGRADE_NOT_INSTALLED` if absent.
2. Resolve target version; throw `UPGRADE_VERSION_NOT_FOUND` if
`node_modules` resolution fails.
3. Short-circuit with `UPGRADE_SAME_VERSION` when installed === target
and not `--force`.
4. Run preflight (lockfile + dirty-tree).
5. Hash current tree; compute snapshot for rollback.
6. Run `plugin.plan` for both versions (or load cached bundle manifests).
7. `computeDiff` → `materialiseDiff` → `Host.applyOps` (existing
transactional path).
8. Run migrations between the same staged-tmp transaction window.
9. On success, `writeState` atomically.
- [ ] 6.2 OTel span `installer.upgrade` wrapping the whole flow with
`plugin.id`, `plugin.version.from`, `plugin.version.to`, `dry_run`.
- [ ] 6.3 `src/host/upgrade/upgrade.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-01`, `INSTALLER-UPGRADE-02`, `INSTALLER-UPGRADE-10`,
`INSTALLER-UPGRADE-11`.
- [ ] 6.4 `src/host/upgrade/apply-diff.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-05`.
## 7. CLI wiring (`src/bin/suaveplan-install.ts`)
- [ ] 7.1 Add `app.command("upgrade <plugin>")` with flags `--dry-run`,
`--force`, `--no-interactive`, `--answers <file>`, `--from <version>`,
`--to <version>`, `--allow-dirty`.
- [ ] 7.2 `runUpgrade(pluginId, flags)` mirrors `runAdd` but calls
`host.upgrade(...)`.
- [ ] 7.3 `src/bin/suaveplan-install.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-12`.
## 8. Testing helpers (`src/testing/`)
- [ ] 8.1 `src/testing/upgrade-plugin.ts` — `upgradePlugin(plugin, { fromState, toBundle, answers? })`
mirroring the existing `runPlugin` harness for upgrade tests.
- [ ] 8.2 `src/testing/fixtures/upgrade-fixture-plugin.ts` — two-version
fixture plugin (`1.0.0` + `2.0.0`) with one migration script, used by
every upgrade conformance test for end-to-end coverage at 100%.
## 9. Docs
- [ ] 9.1 Sibling `.md` for every new implementation `.ts` per
`openspec/AGENTS.md` §4 (Purpose, Features, Basic, Advanced, API, Notes).
- [ ] 9.2 Update root `packages/tooling/installer/README.md` Quickstart to
include the `upgrade` verb and link the new module docs.
## 10. Quality gates
- [ ] 10.1 `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
- [ ] 10.2 `bunx turbo test:coverage --filter=@suaveplan/installer` 100%
istanbul on every new module.
- [ ] 10.3 No banned patterns (`openspec/AGENTS.md` §2) in new files.
- [ ] 10.4 No `biome-ignore` in new files (`openspec/AGENTS.md` §2a).
- [ ] 10.5 File-size check: every `src/host/upgrade/*.ts` ≤ 300 lines,
every `*.test.ts` ≤ 500 lines.
- [ ] 10.6 Sibling `.md` audit returns zero gaps for new files.
## 11. Dependent sweep
- [ ] 11.1 `bunx turbo test --filter=...@suaveplan/installer` green
(validates `@suaveplan/install-agent-skills` against the new surface).
- [ ] 11.2 Add empty `upgrade.ts` migration table export to
`@suaveplan/install-agent-skills` and wire it through its manifest so
the migration-loader path is exercised even with zero migrations.
## 12. Archive
- [ ] 12.1 Fold `specs/installer/spec.md` delta into
`openspec/specs/installer/spec.md`.
- [ ] 12.2 Move `openspec/changes/add-installer-upgrade/` to
`openspec/archive/<yyyy-mm-dd>-add-installer-upgrade/`.