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.