From 6d330557561d66df4d0fe669da1b12dec6febfdb Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 10 May 2026 22:23:28 -0400 Subject: [PATCH] feat: add installer migration framework --- bin/install.js | 97 ++-- docs/ARCHITECTURE.md | 5 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- docs/README.md | 3 +- docs/adr/0008-installer-migration-module.md | 32 ++ docs/adr/README.md | 3 + docs/installer-migrations.md | 376 +++++++++++++++ .../bin/lib/installer-migrations.cjs | 348 ++++++++++++++ .../001-legacy-orphan-files.cjs | 25 + tests/bug-2771-user-profile-manifest.test.cjs | 40 ++ tests/installer-migrations.test.cjs | 431 ++++++++++++++++++ 12 files changed, 1326 insertions(+), 40 deletions(-) create mode 100644 docs/adr/0008-installer-migration-module.md create mode 100644 docs/installer-migrations.md create mode 100644 get-shit-done/bin/lib/installer-migrations.cjs create mode 100644 get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs create mode 100644 tests/installer-migrations.test.cjs diff --git a/bin/install.js b/bin/install.js index cf14f4c50..bdfa4e242 100755 --- a/bin/install.js +++ b/bin/install.js @@ -76,6 +76,9 @@ const { isMinimalMode, stageSkillsForMode, } = require(path.join(_gsdLibDir, 'install-profiles.cjs')); +const { + runInstallerMigrations, +} = require(path.join(_gsdLibDir, 'installer-migrations.cjs')); // Parse args const args = process.argv.slice(2); @@ -6173,24 +6176,6 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand } } -/** - * Clean up orphaned files from previous GSD versions - */ -function cleanupOrphanedFiles(configDir) { - const orphanedFiles = [ - 'hooks/gsd-notify.sh', // Removed in v1.6.x - 'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0 - ]; - - for (const relPath of orphanedFiles) { - const fullPath = path.join(configDir, relPath); - if (fs.existsSync(fullPath)) { - fs.unlinkSync(fullPath); - console.log(` ${green}✓${reset} Removed orphaned ${relPath}`); - } - } -} - /** * Clean up orphaned hook registrations from settings.json */ @@ -7191,6 +7176,32 @@ function generateManifest(dir, baseDir) { return manifest; } +function normalizeInstallRelativePath(relPath) { + if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) { + return null; + } + if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) { + return null; + } + const normalized = relPath.replace(/\\/g, '/'); + const segments = normalized.split('/'); + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) { + return null; + } + return segments.join('/'); +} + +function resolveInstallRelativePath(baseDir, relPath) { + const normalized = normalizeInstallRelativePath(relPath); + if (!normalized) return null; + const root = path.resolve(baseDir); + const fullPath = path.resolve(root, normalized); + if (fullPath !== root && !fullPath.startsWith(root + path.sep)) { + return null; + } + return { relPath: normalized, fullPath }; +} + /** * Write file manifest after installation for future modification detection */ @@ -7333,8 +7344,11 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP let written = 0; try { const topLevels = new Set(); + const safeModified = []; for (const relPath of modified) { - const norm = relPath.replace(/\\/g, '/'); + const norm = normalizeInstallRelativePath(relPath); + if (!norm) continue; + safeModified.push(norm); const slash = norm.indexOf('/'); topLevels.add(slash === -1 ? '' : norm.slice(0, slash)); } @@ -7344,14 +7358,16 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP // Root-level files — copy directly from package source. The transform // pipeline is directory-oriented; root files don't need path-prefix // substitution (they're not markdown content with embedded paths). - for (const relPath of modified) { - const norm = relPath.replace(/\\/g, '/'); + for (const relPath of safeModified) { + const norm = normalizeInstallRelativePath(relPath); + if (!norm) continue; if (norm.includes('/')) continue; - const src = path.join(packageSrc, relPath); - if (!fs.existsSync(src)) continue; - const stagedFile = path.join(stageRoot, relPath); + const srcRef = resolveInstallRelativePath(packageSrc, norm); + const stagedRef = resolveInstallRelativePath(stageRoot, norm); + if (!srcRef || !stagedRef || !fs.existsSync(srcRef.fullPath)) continue; + const stagedFile = stagedRef.fullPath; fs.mkdirSync(path.dirname(stagedFile), { recursive: true }); - fs.copyFileSync(src, stagedFile); + fs.copyFileSync(srcRef.fullPath, stagedFile); } continue; } @@ -7361,15 +7377,15 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal); } - for (const relPath of modified) { + for (const relPath of safeModified) { // Only populate pristine for paths we successfully staged. If a path's // source dir does not exist (obsolete manifest entry), skip silently // rather than corrupting pristine with stale data. - const stagedPath = path.join(stageRoot, relPath); - if (!fs.existsSync(stagedPath)) continue; - const out = path.join(pristineDir, relPath); - fs.mkdirSync(path.dirname(out), { recursive: true }); - fs.copyFileSync(stagedPath, out); + const stagedRef = resolveInstallRelativePath(stageRoot, relPath); + const outRef = resolveInstallRelativePath(pristineDir, relPath); + if (!stagedRef || !outRef || !fs.existsSync(stagedRef.fullPath)) continue; + fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true }); + fs.copyFileSync(stagedRef.fullPath, outRef.fullPath); written++; } } finally { @@ -7408,17 +7424,23 @@ function saveLocalPatches(configDir, pristineCtx) { const patchesDir = path.join(configDir, PATCHES_DIR_NAME); const pristineDir = path.join(configDir, 'gsd-pristine'); const modified = []; + const pristineHashes = {}; for (const [relPath, originalHash] of Object.entries(manifest.files || {})) { - const fullPath = path.join(configDir, relPath); + const safeRef = resolveInstallRelativePath(configDir, relPath); + if (!safeRef) continue; + const { relPath: safeRelPath, fullPath } = safeRef; if (!fs.existsSync(fullPath)) continue; const currentHash = fileHash(fullPath); if (currentHash !== originalHash) { // Back up the user's modified version - const backupPath = path.join(patchesDir, relPath); + const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath); + if (!backupRef) continue; + const backupPath = backupRef.fullPath; fs.mkdirSync(path.dirname(backupPath), { recursive: true }); fs.copyFileSync(fullPath, backupPath); - modified.push(relPath); + modified.push(safeRelPath); + pristineHashes[safeRelPath] = originalHash; } } @@ -7438,7 +7460,7 @@ function saveLocalPatches(configDir, pristineCtx) { // Record the original (pristine) hash for each modified file // This lets the reapply workflow verify reconstructed pristine files for (const relPath of modified) { - meta.pristine_hashes[relPath] = manifest.files[relPath]; + meta.pristine_hashes[relPath] = pristineHashes[relPath]; } fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2)); console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/'); @@ -7598,8 +7620,8 @@ function install(isGlobal, runtime = 'claude') { isGlobal, }); - // Clean up orphaned files from previous versions - cleanupOrphanedFiles(targetDir); + // Run manifest-backed cleanup migrations before package materialization. + runInstallerMigrations({ configDir: targetDir }); // #3245 — Codex idempotent rollback. Capture pre-install state of ALL // directories and files GSD will mutate so that any post-install validation @@ -10749,6 +10771,7 @@ if (process.env.GSD_TEST_MODE) { convertClaudeToCliineMarkdown, convertClaudeAgentToClineAgent, writeManifest, + saveLocalPatches, reportLocalPatches, validateHookFields, preserveUserArtifacts, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c5175f936..e726f335b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -609,6 +609,11 @@ The installer (`bin/install.js`, ~3,000 lines) handles: 8. **Manifest tracking** — Writes `gsd-file-manifest.json` for clean uninstall 9. **Uninstall mode** — `--uninstall` removes all GSD files, hooks, and settings +Install-time file moves, stale-artifact cleanup, config rewrites, and user-data +preservation are governed by the Installer Migration Module. See +[Installer Migrations](installer-migrations.md) and +[ADR 0008](adr/0008-installer-migration-module.md). + ### Platform Handling - **Windows:** `windowsHide` on child processes, EPERM/EACCES protection on protected directories, path separator normalization diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 63d5443c1..1e3b768e7 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-09", + "generated": "2026-05-11", "families": { "agents": [ "gsd-advisor-researcher", @@ -277,6 +277,7 @@ "init-command-router.cjs", "init.cjs", "install-profiles.cjs", + "installer-migrations.cjs", "intel.cjs", "learnings.cjs", "milestone.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 3345929fa..f89271737 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -359,7 +359,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (50 shipped) +## CLI Modules (51 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -385,6 +385,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | +| `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | | `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `milestone.cjs` | Milestone archival, requirements marking | diff --git a/docs/README.md b/docs/README.md index 30d3749fb..c6d3845a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) | Document | Audience | Description | |----------|----------|-------------| | [Architecture](ARCHITECTURE.md) | Contributors, advanced users | System architecture, agent model, data flow, and internal design | +| [Installer Migrations](installer-migrations.md) | Contributors | Architecture for safe install-time migrations, cleanup, preservation, dry-run planning, and rollback | | [Feature Reference](FEATURES.md) | All users | Feature narratives and requirements for released features (see [CHANGELOG](../CHANGELOG.md) for latest additions) | | [Command Reference](COMMANDS.md) | All users | Stable commands with syntax, flags, options, and examples | | [Configuration Reference](CONFIGURATION.md) | All users | Full config schema, workflow toggles, model profiles, git branching | @@ -29,4 +30,4 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - **All commands at a glance:** [Command Reference](COMMANDS.md) - **Configuring GSD:** [Configuration Reference](CONFIGURATION.md) - **How the system works internally:** [Architecture](ARCHITECTURE.md) -- **Contributing or extending:** [CLI Tools Reference](CLI-TOOLS.md) + [Agent Reference](AGENTS.md) \ No newline at end of file +- **Contributing or extending:** [CLI Tools Reference](CLI-TOOLS.md) + [Agent Reference](AGENTS.md) diff --git a/docs/adr/0008-installer-migration-module.md b/docs/adr/0008-installer-migration-module.md new file mode 100644 index 000000000..dfeaf49cf --- /dev/null +++ b/docs/adr/0008-installer-migration-module.md @@ -0,0 +1,32 @@ +# Installer Migration Module owns install-time upgrade safety + +- **Status:** Accepted +- **Date:** 2026-05-11 + +We decided to introduce an explicit Installer Migration Module for install-time file moves, removals, config rewrites, and user-data preservation. Installer upgrade behavior must be represented as versioned migration records that produce a dry-run plan before applying changes. + +## Decision + +- Add an Installer Migration Module as the owner for upgrade migrations. +- Keep the existing installer materialization pipeline, but move cleanup and feature-retirement behavior into migration records over time. +- Track applied migrations in an install-state file next to the existing file manifest. +- Treat the existing file manifest as the managed-file ownership baseline. +- Treat user-owned artifacts as a single shared policy consumed by preservation and manifest writing. +- Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting. +- Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice. +- Support dry-run output using the same planner used by apply mode. + +## Consequences + +- Retiring features requires an explicit migration instead of a hidden cleanup block. +- The installer can remove stale GSD-owned artifacts without guessing about user files. +- Locally modified managed files get a consistent backup path before removal or replacement. +- Future rollback work can become runtime-neutral instead of Codex-specific. +- Migration authors must define ownership evidence, conflict behavior, runtime scope, and non-interactive behavior. +- The installer gains another state file, so tests must cover missing, legacy, and checksum-mismatch state. + +## Scope + +The first implementation should extract manifest/user-owned helpers, add install-state persistence, add migration planning, and port one existing orphan cleanup into the migration runner. It should not rewrite every runtime installer branch in the first pass. + +The detailed module contract lives in `docs/installer-migrations.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index f0c3bff18..7a397be95 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,9 +15,12 @@ Each ADR documents one architectural decision: what was decided, why, and what c | [0005-sdk-architecture-seam-map.md](0005-sdk-architecture-seam-map.md) | SDK Architecture seam map for query/runtime surfaces | Accepted | | [0006-planning-path-projection-module.md](0006-planning-path-projection-module.md) | Planning Path Projection Module for SDK query handlers | Accepted | | [0007-sdk-package-seam-module.md](0007-sdk-package-seam-module.md) | SDK Package Seam Module owns SDK-to-get-shit-done-cc compatibility | Accepted | +| [0008-installer-migration-module.md](0008-installer-migration-module.md) | Installer Migration Module owns install-time upgrade safety | Accepted | ## Seam map ADR 0005 is the top-level SDK seam index. It references per-seam ADRs and states the narrow-waist principle each seam follows. Use it as the entry point for understanding SDK module ownership. ADR 0006 documents how SDK query handlers project planning paths (`cwd → effectiveRoot → .planning//...`). Cross-reference with the Planning Workspace Module (ADR 0004) for workstream pointer policy. + +ADR 0008 documents the Installer Migration Module for safe install-time moves, removals, config rewrites, and user-data preservation. diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md new file mode 100644 index 000000000..e175ec734 --- /dev/null +++ b/docs/installer-migrations.md @@ -0,0 +1,376 @@ +# Installer Migration Architecture + +This document defines the migration layer for GSD installs and upgrades. +It is for contributors who need to retire files, move install surfaces, +rewrite runtime config, or preserve user data while changing how GSD is +installed. + +After reading this document, a contributor should be able to add a new +installer migration without guessing which files are safe to remove or how +to protect local user changes. + +## Problem + +The installer already handles several upgrade behaviors: + +- replacing GSD-managed command, skill, agent, hook, and engine files +- backing up locally modified managed files before replacement +- preserving known user-owned artifacts +- cleaning old hook files and hook registrations +- rewriting runtime-specific configuration formats +- rolling back some failed Codex installs + +Those behaviors are currently distributed across install branches. That +works for isolated fixes, but it makes feature retirement risky. A future +change can remove a file from the package while leaving stale installed +copies behind, or delete a user-created file because it happens to live +inside a GSD-managed directory. + +The migration layer exists to make upgrade behavior explicit, reviewed, and +repeatable. + +## Design Goals + +1. Protect user data by default. +2. Remove stale GSD-managed files when a feature is retired. +3. Make destructive actions visible before they run. +4. Record what happened so future installs do not re-run the same migration. +5. Give each runtime the same safety model, even when the concrete files differ. +6. Keep migration authoring small enough that contributors use it instead of + adding another one-off cleanup block. + +## Non-Goals + +- This is not a general package manager. +- This is not a database migration system. +- This does not automatically infer every historical install layout. +- This does not remove arbitrary user files. +- This does not replace the existing install transforms in one step. + +## Terms + +**Managed file** + +A file that GSD installed and recorded in the install manifest. Managed files +can be replaced automatically when unchanged. If changed locally, they must be +backed up or merged. + +**User-owned file** + +A file created or maintained by a user workflow or by the user directly. These +files must never be removed just because they sit under a GSD directory. + +**Unknown file** + +A file found under an install root that is not in the manifest and is not +classified as user-owned. Unknown files are preserved unless a migration +explicitly classifies them with evidence. + +**Migration** + +A versioned change set that can inspect the current install, produce a plan, +and apply that plan after safety checks pass. + +**Plan** + +A list of proposed filesystem and config actions. A plan is safe to show to a +user. It describes what will happen and why, without mutating disk. + +**Journal** + +A per-run record of applied actions and rollback data. It exists so failed +installs can restore the pre-run state where possible. + +## State Files + +The migration layer uses the existing file manifest and adds one install-state +record. + +### File Manifest + +The existing manifest remains the ownership baseline. It records the installed +GSD version, install mode, and hashes for distribution-owned files. + +The invariant is strict: + +- distribution-owned files are manifest-tracked +- user-owned files are preserved and omitted from manifest hashes +- a path cannot be both + +### Install State + +The installer writes an install-state file next to the manifest. + +Required fields: + +```json +{ + "schema": 1, + "runtime": "codex", + "scope": "global", + "installed_version": "1.50.0", + "install_mode": "full", + "applied_migrations": [ + { + "id": "2026-05-11-codex-hooks-layout", + "package_version": "1.50.0", + "checksum": "sha256:...", + "applied_at": "2026-05-11T00:00:00.000Z" + } + ] +} +``` + +The checksum is calculated from the migration definition. If an applied +migration's checksum changes, the installer must warn and refuse to silently +re-run it. Fix-forward migrations should use a new migration id. + +## Migration Record + +Each migration exports a plain record plus pure planning logic. + +Required fields: + +```js +module.exports = { + id: '2026-05-11-runtime-layout-example', + title: 'Move legacy commands into runtime skills', + introducedIn: '1.50.0', + runtimes: ['claude', 'codex', 'gemini'], + scopes: ['global', 'local'], + destructive: true, + plan(ctx) { + return []; + } +}; +``` + +The `plan(ctx)` function receives an install context with runtime, scope, +target directory, previous manifest, install state, package manifest, and +filesystem helpers. It returns actions. It must not mutate disk. + +Migrations may use helper predicates such as: + +- `isManaged(relPath)` +- `isUserOwned(relPath)` +- `hashMatchesManifest(relPath)` +- `exists(relPath)` +- `readJson(relPath)` +- `readToml(relPath)` + +## Action Types + +Migrations produce a small set of action types. The executor owns mutation, +backup, rollback, and reporting. + +### remove-managed + +Remove a path only when it is known to be GSD-managed and unchanged from the +previous manifest, or when the migration provides a purpose-built detector for +an old GSD-owned shape. + +Use for retired hooks, old generated agents, deprecated command files, and +stale runtime-specific generated artifacts. + +### backup-and-remove + +Back up a managed path before removal because the file differs from the +previous manifest. The user gets a clear report and can inspect the backup. + +Use when a feature retires a managed file that users may have patched. + +### move-managed + +Move a managed path to a new managed path. If the source was locally modified, +the action becomes `backup-and-move` or a conflict. + +Use for layout migrations such as command directories moving into skills. + +### rewrite-config + +Rewrite a structured config file through a parser or existing structural helper. +String replacement is only acceptable for narrowly-scoped marker blocks with +tests for line-ending and ordering variations. + +Use for runtime config, hook registrations, feature flags, and generated +agent registration blocks. + +### preserve-user + +Declare that a path is user-owned and must survive surrounding directory +replacement. This action is informational in dry-run output and becomes a +copy-through or restore operation during apply. + +Use for profile, preferences, hand-authored instructions, and future workflow +outputs. + +### prompt-user + +Stop non-interactive destructive migration and ask in interactive mode. The +prompt must present concrete choices such as preserve, back up, remove, or +move. The default is preserve. + +Use when classification is ambiguous and guessing could lose data. + +## Execution Flow + +The installer runs migrations before materializing the new package payload. + +1. Build install context. +2. Read prior manifest and install state. +3. Build a pre-run snapshot for paths that may be touched. +4. Discover pending migrations by runtime, scope, and applied state. +5. Ask each pending migration for a plan. +6. Merge plans and validate them. +7. Print the plan in dry-run form. +8. Apply safe non-interactive actions. +9. Prompt or stop for ambiguous actions. +10. Write the new package payload. +11. Write the new manifest and install state. +12. Report backups, preserved files, removed stale files, and skipped actions. + +If any apply step fails, the executor uses the journal to restore modified +paths where possible. Rollback must never delete files that were not created +or modified by the current installer run. + +## Dry Run + +The migration runner supports a dry-run mode that prints the plan and exits +without changes. + +Dry-run output groups actions by risk: + +- will preserve +- will replace unchanged managed files +- will remove stale managed files +- will back up locally modified files +- needs user choice +- blocked + +The same planner powers dry-run and apply. There must not be a separate +"preview-only" code path. + +## Safety Policy + +### Ownership + +Never remove an unknown file. Unknown files are preserved unless a migration +contains a specific detector proving the file is a stale GSD artifact. + +### Modification Detection + +When a path is in the previous manifest: + +- hash match means unchanged managed file +- hash mismatch means locally modified managed file +- missing means already removed by the user and should stay removed unless a + migration explicitly needs to recreate it + +### User-Owned Artifacts + +User-owned artifacts are defined once and consumed by both preservation and +manifest-writing code. Adding a user-owned artifact requires a regression test +that proves it is preserved across reinstall and omitted from the manifest. + +### Config Files + +Runtime config is mixed ownership. GSD may own marker blocks, generated agent +sections, or hook entries, but it does not own the whole file unless the file +was created as a GSD-only file. Config migrations should remove or rewrite +only the owned portion. + +### Rollback + +Before applying a migration, the executor records enough data to restore: + +- file bytes before overwrite +- directory membership before removing generated directories +- config bytes before structured rewrite +- paths created by the current run +- temporary files created by atomic writes + +Rollback is best-effort but must be loud when incomplete. + +## First-Time Baseline Migration + +The first migration should classify an existing install rather than attempt +to fix every historical layout. + +It should: + +1. read the current manifest if present +2. scan known runtime install surfaces +3. classify files as managed, user-owned, or unknown +4. report stale GSD-looking files that are not in the current manifest +5. offer actions for ambiguous files instead of deleting them +6. write install state after successful classification + +This baseline is the escape hatch for old installs that predate full migration +tracking. It gives the user a reviewable redistribution/removal plan without +requiring the installer to infer every past release transition perfectly. + +## Authoring Workflow + +When a feature removes or moves install artifacts, the PR must include: + +1. a migration record +2. tests for dry-run plan output +3. tests for apply behavior +4. tests for locally modified managed files +5. tests for user-owned files near the changed path +6. an update to release notes if the migration affects user-visible install + behavior + +The author must answer these questions in the migration file: + +- What old artifact or config shape is being retired? +- How do we prove it is GSD-owned? +- What happens if the user modified it? +- What happens if it is missing? +- What runtime and scope does it affect? +- Is the action safe in non-interactive install? + +## Test Matrix + +Every migration runner change should cover: + +- fresh install with no prior state +- reinstall with matching manifest +- upgrade with pending migration +- locally modified managed file +- unknown file under a GSD directory +- user-owned file under a wiped directory +- failed apply with rollback +- global and local install scopes when applicable +- Windows path separators when paths are serialized +- CRLF input when config files are rewritten + +## Implementation Sequence + +1. Extract install ownership helpers around the manifest and user-owned artifact list. +2. Add install-state read/write helpers. +3. Add migration record discovery and checksum calculation. +4. Add planner-only dry-run support. +5. Add executor with journaled file actions. +6. Port orphaned hook/file cleanup into the first explicit migration. +7. Port one structured config rewrite into the migration runner. +8. Add the baseline classifier for existing installs. +9. Make new install-affecting PRs require migrations when artifacts are moved, + renamed, or retired. + +This sequence keeps the first implementation small: the existing installer +continues to materialize files, while the migration runner takes ownership of +cleanup, classification, and reviewable destructive changes. + +## Prior Art + +The design borrows from established upgrade systems: + +- Flyway versioned migrations: ordered, once-only changes tracked by checksum. +- Flyway dry runs: preview planned mutations before applying them. +- Liquibase changesets and preconditions: declarative changes gated by current + system state. +- Debian conffile policy: preserve local configuration and distinguish package + ownership from user ownership. +- npm lifecycle scripts: useful as packaging context, but not sufficient as the + migration mechanism because uninstall and upgrade context are limited. diff --git a/get-shit-done/bin/lib/installer-migrations.cjs b/get-shit-done/bin/lib/installer-migrations.cjs new file mode 100644 index 000000000..fba3f81eb --- /dev/null +++ b/get-shit-done/bin/lib/installer-migrations.cjs @@ -0,0 +1,348 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; +const INSTALL_STATE_NAME = 'gsd-install-state.json'; +const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations'); + +function sha256File(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function readJsonIfPresent(filePath, fallback) { + if (!fs.existsSync(filePath)) return fallback; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return fallback; + } +} + +function readInstallManifest(configDir) { + const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null); + if (!manifest || typeof manifest !== 'object') { + return { version: null, timestamp: null, mode: null, files: {} }; + } + return { + version: manifest.version || null, + timestamp: manifest.timestamp || null, + mode: manifest.mode || null, + files: manifest.files && typeof manifest.files === 'object' ? manifest.files : {}, + }; +} + +function readInstallState(configDir) { + const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), null); + if (!state || typeof state !== 'object') { + return { schemaVersion: 1, appliedMigrations: [] }; + } + return { + schemaVersion: state.schemaVersion || 1, + appliedMigrations: Array.isArray(state.appliedMigrations) ? state.appliedMigrations : [], + }; +} + +function writeInstallState(configDir, state) { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n', 'utf8'); + return state; +} + +function normalizeRelPath(relPath) { + if (typeof relPath !== 'string' || relPath.trim() === '') { + throw new Error('migration action relPath must be a non-empty string'); + } + const normalized = relPath.replace(/\\/g, '/'); + if (normalized.startsWith('/') || normalized.includes('../') || normalized === '..') { + throw new Error(`migration action relPath must stay inside configDir: ${relPath}`); + } + return normalized; +} + +function classifyArtifact(configDir, relPath, manifest) { + const normalized = normalizeRelPath(relPath); + const originalHash = manifest.files[normalized] || null; + const fullPath = path.join(configDir, normalized); + if (!fs.existsSync(fullPath)) { + return { classification: originalHash ? 'managed-missing' : 'missing', originalHash, currentHash: null }; + } + const currentHash = sha256File(fullPath); + if (!originalHash) { + return { classification: 'unknown', originalHash: null, currentHash }; + } + if (currentHash === originalHash) { + return { classification: 'managed-pristine', originalHash, currentHash }; + } + return { classification: 'managed-modified', originalHash, currentHash }; +} + +function appliedMigrationIds(state) { + return new Set( + state.appliedMigrations + .filter((entry) => entry && typeof entry.id === 'string') + .map((entry) => entry.id) + ); +} + +function validateMigrationRecord(record, source) { + if (!record || typeof record !== 'object') { + throw new Error(`migration record must export an object: ${source}`); + } + if (typeof record.id !== 'string' || record.id.trim() === '') { + throw new Error(`migration record must include a non-empty id: ${source}`); + } + if (typeof record.plan !== 'function') { + throw new Error(`migration record must include a plan function: ${source}`); + } + return record; +} + +function discoverInstallerMigrations({ migrationsDir }) { + if (!migrationsDir || !fs.existsSync(migrationsDir)) return []; + return fs.readdirSync(migrationsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.cjs')) + .map((entry) => entry.name) + .sort() + .flatMap((fileName) => { + const source = path.join(migrationsDir, fileName); + delete require.cache[require.resolve(source)]; + const exported = require(source); + const records = Array.isArray(exported) ? exported : [exported]; + return records.map((record) => validateMigrationRecord(record, source)); + }); +} + +function journalTimestamp(now) { + return now().replace(/[:.]/g, '-'); +} + +function ensureInsideConfig(configDir, relPath) { + const normalized = normalizeRelPath(relPath); + const fullPath = path.resolve(configDir, normalized); + const root = path.resolve(configDir); + if (fullPath !== root && !fullPath.startsWith(root + path.sep)) { + throw new Error(`migration path escapes configDir: ${relPath}`); + } + return { normalized, fullPath }; +} + +function planInstallerMigrations({ configDir, migrations, now = () => new Date().toISOString() }) { + if (!configDir) throw new Error('configDir is required'); + if (!Array.isArray(migrations)) throw new Error('migrations must be an array'); + + const manifest = readInstallManifest(configDir); + const state = readInstallState(configDir); + const applied = appliedMigrationIds(state); + const pending = migrations.filter((migration) => migration && !applied.has(migration.id)); + const actions = []; + const blocked = []; + const classifications = new Map(); + const classify = (relPath) => { + const normalized = normalizeRelPath(relPath); + if (!classifications.has(normalized)) { + classifications.set(normalized, classifyArtifact(configDir, normalized, manifest)); + } + return classifications.get(normalized); + }; + + for (const migration of pending) { + if (typeof migration.id !== 'string' || migration.id.trim() === '') { + throw new Error('migration id must be a non-empty string'); + } + if (typeof migration.plan !== 'function') { + throw new Error(`migration ${migration.id} must provide a plan function`); + } + const plannedActions = migration.plan({ + configDir, + manifest, + state, + now, + classifyArtifact: classify, + }); + if (!Array.isArray(plannedActions)) { + throw new Error(`migration ${migration.id} plan must return an array`); + } + for (const rawAction of plannedActions) { + const relPath = normalizeRelPath(rawAction.relPath); + const classification = classify(relPath); + let protectedType = rawAction.type; + if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') { + protectedType = 'backup-and-remove'; + } + if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') { + protectedType = 'preserve-user'; + } + const action = { + migrationId: migration.id, + type: protectedType, + relPath, + reason: rawAction.reason || migration.description || '', + classification: classification.classification, + originalHash: classification.originalHash, + currentHash: classification.currentHash, + }; + if (action.type !== rawAction.type) { + action.requestedType = rawAction.type; + } + if (action.type === 'backup-and-remove') { + action.backupRelPath = path.posix.join('gsd-migration-backups', migration.id, relPath); + } + if (action.classification === 'unknown') blocked.push(action); + actions.push(action); + } + } + + return { + generatedAt: now(), + manifest, + state, + pendingMigrationIds: pending.map((migration) => migration.id), + actions, + blocked, + }; +} + +function uniqueActionMigrationIds(actions) { + return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))]; +} + +function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) { + if (!configDir) throw new Error('configDir is required'); + if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required'); + if (Array.isArray(plan.blocked) && plan.blocked.length > 0) { + throw new Error(`migration plan has ${plan.blocked.length} blocked action(s)`); + } + + const appliedAt = now(); + const journalRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}.json`); + const journalPath = path.join(configDir, journalRelPath); + const rollbackRootRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}-rollback`); + const rollbackRoot = path.join(configDir, rollbackRootRelPath); + const journal = { + schemaVersion: 1, + appliedAt, + appliedMigrationIds: uniqueActionMigrationIds(plan.actions), + actions: [], + }; + const rollback = []; + + try { + for (const action of plan.actions) { + if (action.type === 'preserve-user') { + journal.actions.push({ ...action, status: 'preserved' }); + continue; + } + if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove') { + throw new Error(`unsupported migration action type: ${action.type}`); + } + + const { normalized, fullPath } = ensureInsideConfig(configDir, action.relPath); + if (!fs.existsSync(fullPath)) { + journal.actions.push({ ...action, status: 'missing' }); + continue; + } + + const rollbackPath = path.join(rollbackRoot, normalized); + fs.mkdirSync(path.dirname(rollbackPath), { recursive: true }); + fs.copyFileSync(fullPath, rollbackPath); + rollback.push({ relPath: normalized, rollbackPath }); + + if (action.type === 'backup-and-remove') { + const backupRelPath = action.backupRelPath || path.posix.join('gsd-migration-backups', action.migrationId, normalized); + const backupPath = path.join(configDir, backupRelPath); + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.copyFileSync(fullPath, backupPath); + journal.actions.push({ ...action, backupRelPath, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' }); + } else { + journal.actions.push({ ...action, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' }); + } + fs.rmSync(fullPath, { force: true }); + } + + fs.mkdirSync(path.dirname(journalPath), { recursive: true }); + fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8'); + + const state = readInstallState(configDir); + const applied = appliedMigrationIds(state); + const nextApplied = [...state.appliedMigrations]; + for (const id of journal.appliedMigrationIds) { + if (!applied.has(id)) { + nextApplied.push({ id, appliedAt, journal: journalRelPath }); + } + } + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: nextApplied, + }); + + return { + appliedMigrationIds: journal.appliedMigrationIds, + journalRelPath, + }; + } catch (error) { + const rollbackFailures = []; + for (const entry of rollback.reverse()) { + const dest = path.join(configDir, entry.relPath); + try { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(entry.rollbackPath, dest); + } catch (rollbackError) { + rollbackFailures.push({ + relPath: entry.relPath, + rollbackPath: entry.rollbackPath, + error: rollbackError.message, + }); + } + } + if (rollbackFailures.length > 0) { + const rollbackError = new Error(`migration apply failed and rollback incomplete: ${error.message}`); + rollbackError.cause = error; + rollbackError.rollbackFailures = rollbackFailures; + throw rollbackError; + } + throw error; + } +} + +function runInstallerMigrations({ + configDir, + migrationsDir = DEFAULT_MIGRATIONS_DIR, + migrations = discoverInstallerMigrations({ migrationsDir }), + now = () => new Date().toISOString(), +} = {}) { + const plan = planInstallerMigrations({ configDir, migrations, now }); + if (plan.actions.length === 0) { + return { + appliedMigrationIds: [], + journalRelPath: null, + plan, + }; + } + if (plan.blocked.length > 0) { + return { + appliedMigrationIds: [], + journalRelPath: null, + plan, + blocked: plan.blocked, + }; + } + const result = applyInstallerMigrationPlan({ configDir, plan, now }); + return { ...result, plan }; +} + +module.exports = { + DEFAULT_MIGRATIONS_DIR, + INSTALL_STATE_NAME, + MANIFEST_NAME, + applyInstallerMigrationPlan, + classifyArtifact, + discoverInstallerMigrations, + planInstallerMigrations, + readInstallManifest, + readInstallState, + runInstallerMigrations, + writeInstallState, +}; diff --git a/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs b/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs new file mode 100644 index 000000000..2540d901d --- /dev/null +++ b/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs @@ -0,0 +1,25 @@ +'use strict'; + +const LEGACY_ORPHAN_FILES = [ + 'hooks/gsd-notify.sh', + 'hooks/statusline.js', +]; + +module.exports = { + id: '2026-05-11-legacy-orphan-files', + description: 'Remove legacy orphan hook files that are still manifest-managed.', + plan: ({ classifyArtifact }) => { + const actions = []; + for (const relPath of LEGACY_ORPHAN_FILES) { + const artifact = classifyArtifact(relPath); + if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') { + actions.push({ + type: 'remove-managed', + relPath, + reason: 'legacy orphan hook file retired by installer migration', + }); + } + } + return actions; + }, +}; diff --git a/tests/bug-2771-user-profile-manifest.test.cjs b/tests/bug-2771-user-profile-manifest.test.cjs index a069f2d34..eeda82adc 100644 --- a/tests/bug-2771-user-profile-manifest.test.cjs +++ b/tests/bug-2771-user-profile-manifest.test.cjs @@ -217,3 +217,43 @@ describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => { ); }); }); + +describe('manifest path safety', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-manifest-path-safety-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('saveLocalPatches ignores manifest entries that escape the install root', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + const outside = path.join(tmpDir, '..', 'outside-managed-file.txt'); + fs.writeFileSync(outside, 'outside user data\n', 'utf8'); + fs.writeFileSync( + path.join(tmpDir, MANIFEST_NAME), + JSON.stringify({ + version: 'legacy', + timestamp: '2026-05-11T00:00:00.000Z', + files: { + '../outside-managed-file.txt': 'deadbeef', + }, + }, null, 2), + 'utf8' + ); + + const modified = mod.saveLocalPatches(tmpDir); + + assert.deepEqual(modified, []); + assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n'); + assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', 'outside-managed-file.txt')), false); + }); +}); diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs new file mode 100644 index 000000000..8796dd328 --- /dev/null +++ b/tests/installer-migrations.test.cjs @@ -0,0 +1,431 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +const { + applyInstallerMigrationPlan, + discoverInstallerMigrations, + planInstallerMigrations, + readInstallState, + runInstallerMigrations, + writeInstallState, +} = require('../get-shit-done/bin/lib/installer-migrations.cjs'); + +function createTempInstall() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function writeFile(root, relPath, content) { + const fullPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +function writeManifest(root, files) { + fs.writeFileSync( + path.join(root, 'gsd-file-manifest.json'), + JSON.stringify({ + version: '1.49.0', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files, + }, null, 2), + 'utf8' + ); +} + +test('plans a pending migration against an unchanged managed file', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n'); + writeManifest(configDir, { + 'hooks/old-hook.js': sha256('managed hook\n'), + }); + + const plan = planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-remove-old-hook', + description: 'Remove retired hook', + plan: () => [ + { + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retired hook', + }, + ], + }, + ], + now: () => '2026-05-11T00:00:00.000Z', + }); + + assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']); + assert.equal(plan.blocked.length, 0); + assert.deepEqual(plan.actions, [ + { + migrationId: '2026-05-11-remove-old-hook', + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retired hook', + classification: 'managed-pristine', + originalHash: sha256('managed hook\n'), + currentHash: sha256('managed hook\n'), + }, + ]); + } finally { + cleanup(configDir); + } +}); + +test('plans backup before removal for a modified managed file', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n'); + writeManifest(configDir, { + 'hooks/old-hook.js': sha256('managed hook\n'), + }); + + const plan = planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-remove-old-hook', + description: 'Remove retired hook', + plan: () => [ + { + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retired hook', + }, + ], + }, + ], + now: () => '2026-05-11T00:00:00.000Z', + }); + + assert.equal(plan.blocked.length, 0); + assert.equal(plan.actions.length, 1); + assert.equal(plan.actions[0].type, 'backup-and-remove'); + assert.equal(plan.actions[0].classification, 'managed-modified'); + assert.equal(plan.actions[0].originalHash, sha256('managed hook\n')); + assert.equal(plan.actions[0].currentHash, sha256('user changed hook\n')); + assert.equal(plan.actions[0].backupRelPath, 'gsd-migration-backups/2026-05-11-remove-old-hook/hooks/old-hook.js'); + } finally { + cleanup(configDir); + } +}); + +test('blocks removal of unknown files by preserving them by default', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n'); + writeManifest(configDir, {}); + + const plan = planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-remove-old-hook', + description: 'Remove retired hook', + plan: () => [ + { + type: 'remove-managed', + relPath: 'hooks/custom-user-hook.js', + reason: 'retired hook', + }, + ], + }, + ], + now: () => '2026-05-11T00:00:00.000Z', + }); + + assert.equal(plan.actions.length, 1); + assert.equal(plan.actions[0].type, 'preserve-user'); + assert.equal(plan.actions[0].requestedType, 'remove-managed'); + assert.equal(plan.actions[0].classification, 'unknown'); + assert.deepEqual(plan.blocked, [plan.actions[0]]); + } finally { + cleanup(configDir); + } +}); + +test('applies an unblocked plan with a journal and install-state update', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n'); + writeManifest(configDir, { + 'hooks/old-hook.js': sha256('managed hook\n'), + }); + + const plan = planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-remove-old-hook', + description: 'Remove retired hook', + plan: () => [ + { + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retired hook', + }, + ], + }, + ], + now: () => '2026-05-11T00:00:00.000Z', + }); + + const result = applyInstallerMigrationPlan({ + configDir, + plan, + now: () => '2026-05-11T00:00:01.000Z', + }); + + assert.equal(fs.existsSync(path.join(configDir, 'hooks/old-hook.js')), false); + assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-remove-old-hook']); + assert.equal(result.journalRelPath, 'gsd-migration-journal/2026-05-11T00-00-01-000Z.json'); + + const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8')); + assert.deepEqual(journal.appliedMigrationIds, ['2026-05-11-remove-old-hook']); + assert.equal(journal.actions[0].relPath, 'hooks/old-hook.js'); + + const state = readInstallState(configDir); + assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']); + } finally { + cleanup(configDir); + } +}); + +test('rolls back touched files and leaves state unchanged when apply fails', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n'); + writeManifest(configDir, { + 'hooks/old-hook.js': sha256('managed hook\n'), + }); + + const plan = { + pendingMigrationIds: ['2026-05-11-remove-old-hook'], + blocked: [], + actions: [ + { + migrationId: '2026-05-11-remove-old-hook', + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retired hook', + classification: 'managed-pristine', + originalHash: sha256('managed hook\n'), + currentHash: sha256('managed hook\n'), + }, + { + migrationId: '2026-05-11-remove-old-hook', + type: 'unsupported-test-action', + relPath: 'hooks/other.js', + reason: 'force failure', + classification: 'managed-pristine', + originalHash: null, + currentHash: null, + }, + ], + }; + + assert.throws( + () => applyInstallerMigrationPlan({ + configDir, + plan, + now: () => '2026-05-11T00:00:02.000Z', + }), + /unsupported migration action type/ + ); + + assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n'); + assert.deepEqual(readInstallState(configDir).appliedMigrations, []); + assert.equal(fs.existsSync(path.join(configDir, 'gsd-migration-journal', '2026-05-11T00-00-02-000Z.json')), false); + } finally { + cleanup(configDir); + } +}); + +test('reports rollback restore failures instead of swallowing them', () => { + const configDir = createTempInstall(); + const originalCopyFileSync = fs.copyFileSync; + try { + writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n'); + writeManifest(configDir, { + 'hooks/old-hook.js': sha256('managed hook\n'), + }); + + const plan = { + blocked: [], + actions: [ + { + migrationId: '2026-05-11-remove-old-hook', + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retired hook', + classification: 'managed-pristine', + originalHash: sha256('managed hook\n'), + currentHash: sha256('managed hook\n'), + }, + { + migrationId: '2026-05-11-remove-old-hook', + type: 'unsupported-test-action', + relPath: 'hooks/other.js', + reason: 'force failure', + classification: 'managed-pristine', + originalHash: null, + currentHash: null, + }, + ], + }; + + fs.copyFileSync = (src, dest) => { + if (String(src).includes('2026-05-11T00-00-04-000Z-rollback')) { + throw new Error('simulated rollback copy failure'); + } + return originalCopyFileSync(src, dest); + }; + + assert.throws( + () => applyInstallerMigrationPlan({ + configDir, + plan, + now: () => '2026-05-11T00:00:04.000Z', + }), + (error) => { + assert.match(error.message, /rollback incomplete/); + assert.equal(error.rollbackFailures.length, 1); + assert.equal(error.rollbackFailures[0].relPath, 'hooks/old-hook.js'); + return true; + } + ); + } finally { + fs.copyFileSync = originalCopyFileSync; + cleanup(configDir); + } +}); + +test('skips migration records already present in install state', () => { + const configDir = createTempInstall(); + try { + writeManifest(configDir, {}); + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [ + { + id: '2026-05-11-remove-old-hook', + appliedAt: '2026-05-11T00:00:00.000Z', + journal: 'gsd-migration-journal/prior.json', + }, + ], + }); + + const plan = planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-remove-old-hook', + description: 'Remove retired hook', + plan: () => { + throw new Error('already-applied migration planner must not run'); + }, + }, + ], + now: () => '2026-05-11T00:00:03.000Z', + }); + + assert.deepEqual(plan.pendingMigrationIds, []); + assert.deepEqual(plan.actions, []); + assert.deepEqual(plan.blocked, []); + } finally { + cleanup(configDir); + } +}); + +test('discovers migration records from a directory in filename order', () => { + const configDir = createTempInstall(); + try { + const migrationsDir = path.join(configDir, 'migrations'); + fs.mkdirSync(migrationsDir, { recursive: true }); + fs.writeFileSync( + path.join(migrationsDir, '002-second.cjs'), + "module.exports = { id: 'second', description: 'second', plan: () => [] };\n", + 'utf8' + ); + fs.writeFileSync( + path.join(migrationsDir, '001-first.cjs'), + "module.exports = { id: 'first', description: 'first', plan: () => [] };\n", + 'utf8' + ); + + const migrations = discoverInstallerMigrations({ migrationsDir }); + + assert.deepEqual(migrations.map((migration) => migration.id), ['first', 'second']); + } finally { + cleanup(configDir); + } +}); + +test('rejects migration actions that escape the install root', () => { + const configDir = createTempInstall(); + try { + writeManifest(configDir, {}); + + assert.throws( + () => planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-bad-path', + description: 'Bad path', + plan: () => [ + { + type: 'remove-managed', + relPath: 'hooks/../../outside.js', + reason: 'bad path', + }, + ], + }, + ], + }), + /relPath must stay inside configDir/ + ); + } finally { + cleanup(configDir); + } +}); + +test('runs discovered installer migrations against manifest-managed legacy orphan files', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/statusline.js', 'legacy managed hook\n'); + writeFile(configDir, 'hooks/custom.js', 'custom hook\n'); + writeManifest(configDir, { + 'hooks/statusline.js': sha256('legacy managed hook\n'), + }); + + const result = runInstallerMigrations({ + configDir, + now: () => '2026-05-11T00:00:05.000Z', + }); + + assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false); + assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom.js'), 'utf8'), 'custom hook\n'); + assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-legacy-orphan-files']); + assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['2026-05-11-legacy-orphan-files']); + } finally { + cleanup(configDir); + } +});