801 lines
24 KiB
JavaScript
801 lines
24 KiB
JavaScript
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,
|
|
INSTALL_STATE_NAME,
|
|
planInstallerMigrations,
|
|
readInstallState,
|
|
runInstallerMigrations,
|
|
writeInstallState,
|
|
} = require('../get-shit-done/bin/lib/installer-migrations.cjs');
|
|
const firstTimeBaselineMigration = require('../get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.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'
|
|
);
|
|
}
|
|
|
|
function legacyCodexHook(configDir) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function userHook(command) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
test('records a first-time baseline while preserving user-owned artifacts', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'get-shit-done/workflows/plan.md', 'managed workflow\n');
|
|
writeFile(configDir, 'get-shit-done/USER-PROFILE.md', 'user profile\n');
|
|
writeManifest(configDir, {
|
|
'get-shit-done/workflows/plan.md': sha256('managed workflow\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-first-time-baseline-scan']);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'get-shit-done/workflows/plan.md'), 'utf8'), 'managed workflow\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'get-shit-done/USER-PROFILE.md'), 'utf8'), 'user profile\n');
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'get-shit-done/workflows/plan.md',
|
|
classification: 'managed-pristine',
|
|
},
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'get-shit-done/USER-PROFILE.md',
|
|
classification: 'user-owned',
|
|
},
|
|
]
|
|
);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-first-time-baseline-scan',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves unknown files discovered in known install surfaces by default', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.blocked, undefined);
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
classification: 'unknown',
|
|
},
|
|
]
|
|
);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom-user-hook.js'), 'utf8'), 'user hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-first-time-baseline-scan',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('blocks stale GSD-looking baseline artifacts for explicit user choice', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/gsd-retired-hook.js', 'old gsd hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:02.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(result.journalRelPath, null);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/gsd-retired-hook.js'), 'utf8'), 'old gsd hook\n');
|
|
assert.deepEqual(
|
|
result.blocked.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
choices: action.choices,
|
|
})),
|
|
[
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'hooks/gsd-retired-hook.js',
|
|
classification: 'stale-gsd-looking',
|
|
choices: ['keep', 'remove'],
|
|
},
|
|
]
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
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.equal(plan.actions.length, 1);
|
|
assert.deepEqual(
|
|
{
|
|
migrationId: plan.actions[0].migrationId,
|
|
type: plan.actions[0].type,
|
|
relPath: plan.actions[0].relPath,
|
|
reason: plan.actions[0].reason,
|
|
classification: plan.actions[0].classification,
|
|
originalHash: plan.actions[0].originalHash,
|
|
currentHash: plan.actions[0].currentHash,
|
|
},
|
|
{
|
|
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'),
|
|
}
|
|
);
|
|
assert.match(plan.actions[0].migrationChecksum, /^sha256:/);
|
|
} 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']);
|
|
assert.match(state.appliedMigrations[0].checksum, /^sha256:/);
|
|
} 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('rejects executable preserve-user actions because preservation blocks non-interactive apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-preserve-user',
|
|
type: 'preserve-user',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
reason: 'unknown user hook',
|
|
classification: 'unknown',
|
|
originalHash: null,
|
|
currentHash: sha256('user hook\n'),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
/unsupported migration action type: preserve-user/
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('keeps prior install state intact when a state write fails mid-write', () => {
|
|
const configDir = createTempInstall();
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
try {
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [{ id: 'already-safe', appliedAt: '2026-05-11T00:00:00.000Z' }],
|
|
});
|
|
|
|
fs.writeFileSync = (filePath, content, ...rest) => {
|
|
if (path.basename(filePath).startsWith(`${INSTALL_STATE_NAME}.tmp-`)) {
|
|
throw new Error('simulated temp state write failure');
|
|
}
|
|
return originalWriteFileSync(filePath, content, ...rest);
|
|
};
|
|
|
|
assert.throws(
|
|
() => writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [{ id: 'new-migration', appliedAt: '2026-05-11T00:00:01.000Z' }],
|
|
}),
|
|
/simulated temp state write failure/
|
|
);
|
|
} finally {
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
}
|
|
|
|
try {
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-safe']);
|
|
} finally {
|
|
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('refuses to plan an already-applied migration whose checksum changed', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
checksum: 'sha256:old-definition',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.throws(
|
|
() => planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
checksum: 'sha256:new-definition',
|
|
description: 'Remove retired hook',
|
|
plan: () => [],
|
|
},
|
|
],
|
|
}),
|
|
/applied migration checksum changed/
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('ignores checksum drift for applied migrations outside the active runtime scope', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-codex-only',
|
|
checksum: 'sha256:old-definition',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [
|
|
{
|
|
id: '2026-05-11-codex-only',
|
|
checksum: 'sha256:new-definition',
|
|
runtimes: ['codex'],
|
|
scopes: ['global'],
|
|
description: 'Codex-only migration',
|
|
plan: () => {
|
|
throw new Error('out-of-scope migration planner must not run');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
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,
|
|
scope: 'global',
|
|
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);
|
|
}
|
|
});
|
|
|
|
test('runs a Codex legacy hooks.json cleanup migration without removing user hooks', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [
|
|
legacyCodexHook(configDir),
|
|
userHook('node "/Users/example/bin/user-hook.js"'),
|
|
userHook('node "/Users/example/bin/gsd-check-update.js"'),
|
|
],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
|
|
assert.deepEqual(commands, [
|
|
'node "/Users/example/bin/user-hook.js"',
|
|
'node "/Users/example/bin/gsd-check-update.js"',
|
|
]);
|
|
assert.ok(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'));
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('skips runtime-specific migration records for other runtimes', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [legacyCodexHook(configDir)],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:07.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
assert.equal(hooksJson.SessionStart[0].hooks[0].command, `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`);
|
|
assert.equal(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'), false);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|