feat: add installer migration framework

This commit is contained in:
Tom Boucher
2026-05-10 22:23:28 -04:00
parent 574d1f448c
commit 6d33055756
12 changed files with 1326 additions and 40 deletions

View File

@@ -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,