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,
|
||||
|
||||
Reference in New Issue
Block a user