feat: add installer migration framework
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
- **Contributing or extending:** [CLI Tools Reference](CLI-TOOLS.md) + [Agent Reference](AGENTS.md)
|
||||
|
||||
32
docs/adr/0008-installer-migration-module.md
Normal file
32
docs/adr/0008-installer-migration-module.md
Normal file
@@ -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`.
|
||||
@@ -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/<project>/...`). 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.
|
||||
|
||||
376
docs/installer-migrations.md
Normal file
376
docs/installer-migrations.md
Normal file
@@ -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.
|
||||
348
get-shit-done/bin/lib/installer-migrations.cjs
Normal file
348
get-shit-done/bin/lib/installer-migrations.cjs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
431
tests/installer-migrations.test.cjs
Normal file
431
tests/installer-migrations.test.cjs
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user