Merge main into phase 5 installer migrations
This commit is contained in:
@@ -7134,9 +7134,35 @@ function resolveInstallRelativePath(baseDir, relPath) {
|
||||
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
if (hasExistingSymlinkBetween(root, fullPath)) {
|
||||
return null;
|
||||
}
|
||||
return { relPath: normalized, fullPath };
|
||||
}
|
||||
|
||||
function hasExistingSymlinkBetween(root, fullPath) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedFullPath = path.resolve(fullPath);
|
||||
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cursor = resolvedRoot;
|
||||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const relative = path.relative(resolvedRoot, resolvedFullPath);
|
||||
for (const segment of relative.split(path.sep)) {
|
||||
if (!segment) continue;
|
||||
cursor = path.join(cursor, segment);
|
||||
if (!fs.existsSync(cursor)) return false;
|
||||
if (fs.lstatSync(cursor).isSymbolicLink()) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file manifest after installation for future modification detection
|
||||
*/
|
||||
@@ -7554,6 +7580,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
|
||||
// Track installation failures
|
||||
const failures = [];
|
||||
let installerMigrationResult = null;
|
||||
const rollbackInstallerMigrations = () => {
|
||||
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
|
||||
const rollback = installerMigrationResult.rollback;
|
||||
installerMigrationResult = null;
|
||||
rollback();
|
||||
};
|
||||
|
||||
// Save any locally modified GSD files before they get wiped.
|
||||
// The pristine context lets saveLocalPatches populate gsd-pristine/ via
|
||||
@@ -7566,6 +7599,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
isGlobal,
|
||||
});
|
||||
|
||||
// Run manifest-backed cleanup migrations before package materialization.
|
||||
installerMigrationResult = 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
|
||||
// failure (config.toml schema check, write failure, etc.) can revert the
|
||||
@@ -7596,13 +7632,6 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
|
||||
const codexPreInstallAgentContents = new Map();
|
||||
let codexPreInstallVersionBytes = null;
|
||||
let installerMigrationResult = null;
|
||||
const rollbackInstallerMigrations = () => {
|
||||
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
|
||||
const rollback = installerMigrationResult.rollback;
|
||||
installerMigrationResult = null;
|
||||
rollback();
|
||||
};
|
||||
if (isCodex && !isMinimalMode(installMode)) {
|
||||
const _preSkillsDir = path.join(targetDir, 'skills');
|
||||
if (fs.existsSync(_preSkillsDir)) {
|
||||
@@ -8387,6 +8416,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
|
||||
// agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
|
||||
// rollback so the caller is never left in a partially-installed state.
|
||||
rollbackInstallerMigrations();
|
||||
if (_codexPreConfigRollback) {
|
||||
_codexPreConfigRollback();
|
||||
}
|
||||
@@ -9897,6 +9927,12 @@ function installSdkIfNeeded(opts) {
|
||||
if (!fs.existsSync(sdkCliPath)) {
|
||||
const ir = buildSdkFailFastReport(sdkDir, sdkCliPath);
|
||||
renderSdkFailFastReport(ir);
|
||||
if (opts.throwOnFailure) {
|
||||
const error = new Error(`GSD SDK prebuilt artifact missing: ${sdkCliPath}`);
|
||||
error.code = 'GSD_SDK_MISSING_DIST';
|
||||
error.exitCode = 1;
|
||||
throw error;
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -10585,14 +10621,6 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
migrationsDir: path.join(_gsdLibDir, 'installer-migrations'),
|
||||
});
|
||||
|
||||
for (const runtime of runtimes) {
|
||||
const result = install(isGlobal, runtime, { installerMigrations });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const statuslineRuntimes = ['claude', 'gemini'];
|
||||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
||||
|
||||
const rollbackFinalizedInstallerMigrations = (error) => {
|
||||
const rollbackFailures = [];
|
||||
for (const result of [...results].reverse()) {
|
||||
@@ -10611,6 +10639,19 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const runtime of runtimes) {
|
||||
const result = install(isGlobal, runtime, { installerMigrations });
|
||||
results.push(result);
|
||||
}
|
||||
} catch (error) {
|
||||
rollbackFinalizedInstallerMigrations(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const statuslineRuntimes = ['claude', 'gemini'];
|
||||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
||||
|
||||
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
|
||||
try {
|
||||
// Verify sdk/dist/cli.js is present and executable. The dist is shipped
|
||||
@@ -10618,7 +10659,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
// the parent package's bin/gsd-sdk.js shim, so no sub-install is needed.
|
||||
// Skip with --no-sdk. Skip with isLocal (#2678 — local installs don't own global npm).
|
||||
// #3033: pass forceSdk so --sdk overrides the local-install skip.
|
||||
installSdkIfNeeded({ isLocal: !isGlobal, forceSdk: hasSdk });
|
||||
installSdkIfNeeded({ isLocal: !isGlobal, forceSdk: hasSdk, throwOnFailure: true });
|
||||
|
||||
const printSummaries = () => {
|
||||
for (const result of results) {
|
||||
|
||||
@@ -274,8 +274,8 @@ entry point for every supported runtime: Claude Code, Antigravity, Augment,
|
||||
Cline, CodeBuddy, Codex, Copilot, Cursor, Gemini, Hermes Agent, Kilo, OpenCode,
|
||||
Qwen Code, Trae, and Windsurf. The installer invokes the same migration runner
|
||||
with `baselineScan: true`, reports the projected action rows, applies safe
|
||||
non-interactive actions before materialization, writes install state after a
|
||||
successful apply, and fails before writing new package files when the runner
|
||||
non-interactive actions before materialization, persists install state only after
|
||||
package materialization and finalization succeed, and fails before writing new package files when the runner
|
||||
returns blocked user-choice actions.
|
||||
|
||||
Phase 1-3 built the planning, apply, rollback, install-state, baseline, and
|
||||
@@ -360,7 +360,7 @@ for the new shape before changing migration behavior.
|
||||
| Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 |
|
||||
| Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].codex_hooks` when added by GSD, and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-11; installer compatibility sentinel: Codex 0.124.0 agent table shape |
|
||||
| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 |
|
||||
| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete on 2026-05-11; GSD uses the Gemini-compatible compatibility-shim settings contract |
|
||||
| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Checked 2026-05-11 against available Antigravity install/config material; this row records GSD's Gemini-compatible settings contract as the compatibility baseline for installer migrations. |
|
||||
| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 |
|
||||
| Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules |
|
||||
| Augment Code | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/` | Global `AUGMENT_CONFIG_DIR` or `~/.augment`; local `./.augment` | GSD owns generated skills/agents only; no hook/statusline ownership | [Augment Agent Skills](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 |
|
||||
|
||||
@@ -94,10 +94,14 @@ function normalizeRelPath(relPath) {
|
||||
throw new Error('migration action relPath must be a non-empty string');
|
||||
}
|
||||
const normalized = relPath.replace(/\\/g, '/');
|
||||
if (normalized.startsWith('/') || normalized.includes('../') || normalized === '..') {
|
||||
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
||||
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
|
||||
}
|
||||
return normalized;
|
||||
const segments = normalized.split('/');
|
||||
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
||||
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function classifyArtifact(configDir, relPath, manifest) {
|
||||
@@ -183,13 +187,10 @@ function discoverInstallerMigrations({ migrationsDir }) {
|
||||
.sort()
|
||||
.flatMap((fileName) => {
|
||||
const source = path.join(migrationsDir, fileName);
|
||||
const checksum = `sha256:${sha256File(source)}`;
|
||||
delete require.cache[require.resolve(source)];
|
||||
const exported = require(source);
|
||||
const records = Array.isArray(exported) ? exported : [exported];
|
||||
return records.map((record) =>
|
||||
validateInstallerMigrationRecord({ ...record, checksum: record.checksum || checksum }, source)
|
||||
);
|
||||
return records.map((record) => validateInstallerMigrationRecord(record, source));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -386,6 +387,7 @@ function planInstallerMigrations({
|
||||
manifest,
|
||||
state,
|
||||
pendingMigrationIds: pending.map((migration) => migration.id),
|
||||
pendingMigrations: pending,
|
||||
actions,
|
||||
blocked,
|
||||
};
|
||||
@@ -478,6 +480,9 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
: null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
||||
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
|
||||
|
||||
for (const action of plan.actions) {
|
||||
if (
|
||||
action.type !== 'remove-managed' &&
|
||||
@@ -537,7 +542,6 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
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);
|
||||
@@ -596,6 +600,38 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
}
|
||||
}
|
||||
|
||||
function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().toISOString() }) {
|
||||
if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const appliedAt = now();
|
||||
const state = readInstallState(configDir);
|
||||
const applied = appliedMigrationIds(state);
|
||||
const checksumsByMigrationId = new Map();
|
||||
for (const migration of plan.pendingMigrations || []) {
|
||||
checksumsByMigrationId.set(migration.id, migrationChecksum(migration));
|
||||
}
|
||||
const nextApplied = [...state.appliedMigrations];
|
||||
const newlyApplied = [];
|
||||
for (const id of plan.pendingMigrationIds) {
|
||||
if (applied.has(id)) continue;
|
||||
nextApplied.push({
|
||||
id,
|
||||
appliedAt,
|
||||
journal: null,
|
||||
checksum: checksumsByMigrationId.get(id) || null,
|
||||
});
|
||||
newlyApplied.push(id);
|
||||
}
|
||||
if (newlyApplied.length > 0) {
|
||||
writeInstallState(configDir, {
|
||||
schemaVersion: 1,
|
||||
appliedMigrations: nextApplied,
|
||||
});
|
||||
}
|
||||
return newlyApplied;
|
||||
}
|
||||
|
||||
function runInstallerMigrations({
|
||||
configDir,
|
||||
runtime = null,
|
||||
@@ -612,9 +648,10 @@ function runInstallerMigrations({
|
||||
try {
|
||||
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
|
||||
if (plan.actions.length === 0) {
|
||||
const appliedMigrationIds = markPendingMigrationsApplied({ configDir, plan, now });
|
||||
completed = true;
|
||||
return {
|
||||
appliedMigrationIds: [],
|
||||
appliedMigrationIds,
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ const { describe, test, beforeEach, afterEach, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
@@ -220,9 +221,16 @@ describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => {
|
||||
|
||||
describe('manifest path safety', () => {
|
||||
let tmpDir;
|
||||
let outside;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-manifest-path-safety-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-manifest-path-safety-');
|
||||
outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`);
|
||||
});
|
||||
afterEach(() => {
|
||||
if (outside) fs.rmSync(outside, { recursive: true, force: true });
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('saveLocalPatches ignores manifest entries that escape the install root', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
@@ -236,7 +244,6 @@ describe('manifest path safety', () => {
|
||||
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),
|
||||
@@ -254,6 +261,46 @@ describe('manifest path safety', () => {
|
||||
|
||||
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);
|
||||
assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false);
|
||||
});
|
||||
|
||||
test('saveLocalPatches does not follow symlinked patch directories outside 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 hookPath = path.join(tmpDir, 'hooks', 'managed.js');
|
||||
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
||||
fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, MANIFEST_NAME),
|
||||
JSON.stringify({
|
||||
version: 'legacy',
|
||||
timestamp: '2026-05-11T00:00:00.000Z',
|
||||
files: {
|
||||
'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'),
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const modified = mod.saveLocalPatches(tmpDir);
|
||||
|
||||
assert.deepEqual(modified, []);
|
||||
assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,21 @@ describe('bug #3033: --sdk flag (opts.forceSdk) must be wired into installSdkIfN
|
||||
);
|
||||
});
|
||||
|
||||
test('throwOnFailure=true converts missing SDK dist into catchable error', () => {
|
||||
fs.mkdirSync(sdkDir, { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() => {
|
||||
installSdkIfNeeded({ sdkDir, isLocal: true, forceSdk: true, throwOnFailure: true });
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'GSD_SDK_MISSING_DIST');
|
||||
assert.equal(error.exitCode, 1);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('forceSdk=false (default) + isLocal=true + dist missing: retains #2678 soft-skip', () => {
|
||||
// Verify the #2678 contract is not broken for the default (no --sdk) path.
|
||||
fs.mkdirSync(sdkDir, { recursive: true });
|
||||
|
||||
@@ -104,6 +104,34 @@ function withWriteFailure(matchPath, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function withSdkDistPresent(fn) {
|
||||
const sdkCliPath = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalStatSync = fs.statSync;
|
||||
const originalChmodSync = fs.chmodSync;
|
||||
fs.existsSync = (filePath) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return true;
|
||||
return originalExistsSync.call(fs, filePath);
|
||||
};
|
||||
fs.statSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) {
|
||||
return { mode: 0o755 };
|
||||
}
|
||||
return originalStatSync.call(fs, filePath, ...args);
|
||||
};
|
||||
fs.chmodSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return;
|
||||
return originalChmodSync.call(fs, filePath, ...args);
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.statSync = originalStatSync;
|
||||
fs.chmodSync = originalChmodSync;
|
||||
}
|
||||
}
|
||||
|
||||
function stripAnsi(value) {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
@@ -315,8 +343,10 @@ describe('installer migration install integration', { concurrency: false }, () =
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
||||
withWriteFailure(path.join(claudeHome, 'settings.json'), () =>
|
||||
installModule.installAllRuntimes(['claude'], true, false)
|
||||
withSdkDistPresent(() =>
|
||||
withWriteFailure(path.join(claudeHome, 'settings.json'), () =>
|
||||
installModule.installAllRuntimes(['claude'], true, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -330,6 +360,44 @@ describe('installer migration install integration', { concurrency: false }, () =
|
||||
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
||||
});
|
||||
|
||||
test('rolls back completed runtime migrations when a later runtime install fails', () => {
|
||||
const claudeHome = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
||||
writeManifest(claudeHome, {
|
||||
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
||||
});
|
||||
|
||||
writeFile(codexHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
||||
writeManifest(codexHome, {
|
||||
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
||||
withEnv('CODEX_HOME', codexHome, () =>
|
||||
withWriteFailure(path.join(codexHome, 'get-shit-done', 'VERSION'), () =>
|
||||
installModule.installAllRuntimes(['claude', 'codex'], true, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
/injected write failure for VERSION/
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
||||
'legacy managed hook\n'
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(codexHome, 'hooks/statusline.js'), 'utf8'),
|
||||
'legacy managed hook\n'
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-install-state.json')), false);
|
||||
});
|
||||
|
||||
for (const runtime of SUPPORTED_RUNTIMES) {
|
||||
test(`runs a full end-to-end install for ${runtime}`, () => {
|
||||
const targetDir = path.join(tmpRoot, `.${runtime}-full-install`);
|
||||
|
||||
@@ -703,6 +703,64 @@ test('reports lock release failures after migration work completes', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rollback handle restores files and install state after a successful apply', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
||||
writeManifest(configDir, {
|
||||
'hooks/old-hook.js': sha256('managed hook\n'),
|
||||
});
|
||||
writeInstallState(configDir, {
|
||||
schemaVersion: 1,
|
||||
appliedMigrations: [
|
||||
{
|
||||
id: 'already-applied',
|
||||
appliedAt: '2026-05-10T00:00:00.000Z',
|
||||
journal: 'gsd-migration-journal/prior.json',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const plan = planInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [
|
||||
migrationRecord({
|
||||
id: '2026-05-11-remove-old-hook',
|
||||
title: 'Remove retired hook',
|
||||
description: 'Remove retired hook',
|
||||
introducedIn: '1.50.0',
|
||||
scopes: ['global'],
|
||||
destructive: true,
|
||||
plan: () => [
|
||||
{
|
||||
type: 'remove-managed',
|
||||
relPath: 'hooks/old-hook.js',
|
||||
reason: 'retired hook',
|
||||
ownershipEvidence: 'test fixture manifest-managed hook',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
scope: 'global',
|
||||
now: () => '2026-05-11T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan,
|
||||
now: () => '2026-05-11T00:00:01.000Z',
|
||||
});
|
||||
|
||||
result.rollback();
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
||||
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-applied']);
|
||||
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('rolls back touched files and leaves state unchanged when apply fails', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
@@ -971,6 +1029,36 @@ test('skips migration records already present in install state', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('marks zero-action pending migrations as applied', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const result = runInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [
|
||||
migrationRecord({
|
||||
id: '2026-05-11-noop-cleanup',
|
||||
title: 'No-op cleanup',
|
||||
description: 'No-op cleanup',
|
||||
destructive: false,
|
||||
plan: () => [],
|
||||
}),
|
||||
],
|
||||
scope: 'global',
|
||||
now: () => '2026-05-11T00:00:06.000Z',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-noop-cleanup']);
|
||||
assert.equal(result.journalRelPath, null);
|
||||
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
||||
'2026-05-11-noop-cleanup',
|
||||
]);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to plan an already-applied migration whose checksum changed', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
@@ -1104,6 +1192,40 @@ test('rejects migration actions that escape the install root', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects migration actions that normalize to the install root', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
writeManifest(configDir, {});
|
||||
|
||||
for (const relPath of ['.', 'hooks/..']) {
|
||||
assert.throws(
|
||||
() => planInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [
|
||||
migrationRecord({
|
||||
id: `2026-05-11-bad-path-${relPath.replace(/[^a-z0-9]/gi, '-')}`,
|
||||
title: 'Bad path',
|
||||
description: 'Bad path',
|
||||
plan: () => [
|
||||
{
|
||||
type: 'remove-managed',
|
||||
relPath,
|
||||
reason: 'bad path',
|
||||
ownershipEvidence: 'test fixture manifest-managed hook',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
scope: 'global',
|
||||
}),
|
||||
/relPath must stay inside configDir/
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('runs discovered installer migrations against manifest-managed legacy orphan files', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user