Files
msd-core/tests/installer-migrations.test.cjs
Tom Boucher 74a121bb4f fix(#934): reapply verifier handles missing pristine baseline post-rename (#937)
* fix(#934): reapply verifier handles missing pristine baseline post-rename

Gap 1 (verify-reapply-patches.cjs): when backup-meta.json records a
pristine_hash for a file but gsd-pristine/ has no corresponding snapshot
on disk, the verifier fell to over-broad mode and produced false
FAIL_USER_LINES_MISSING. Fix: return advisory OK_NO_BASELINE (non-blocking,
exit 0) so the verifier does not block on files it cannot reason about.

Gap 2 (new migration 004): migration 003 removed legacy get-shit-done/
runtime files but left gsd-pristine/get-shit-done/ orphan snapshots in
place. Those stale snapshots referenced get-shit-done/... key paths that
no longer match the active gsd-core/... layout. Fix: add migration
004-prune-stale-pristine-get-shit-done (NOT editing 003, preserving its
checksum — ref #670 guard) to remove all files under
gsd-pristine/get-shit-done/ as GSD-managed pristine snapshots.

Includes tests: bug-934 OK_NO_BASELINE assertions in the verifier test,
new installer-migration-prune-stale-pristine.test.cjs, updated
installer-migrations baseline-lock checksum for 004.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#934): rename migration to satisfy legacy-name guard + mark intentional path refs

Rename src/installer-migrations/004-prune-stale-pristine-get-shit-done.cts
→ 004-prune-stale-pristine-snapshots.cts so the filename no longer contains the
forbidden token.  Update .gitignore and eslint.config.mjs to track the new built
path.  Add gsd-allow-legacy-name markers to the remaining intentional uses of the
legacy path string in the migration body (lines 3 and 100) and in tests
(installer-migration-prune-stale-pristine.test.cjs lines 202 and 226; and the
baseline-lock key in installer-migrations.test.cjs:1469).  Update the baseline
checksum for migration 2026-06-09-prune-stale-pristine-get-shit-done to reflect
the two new marker comments added to its body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:51:15 -04:00

1581 lines
50 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,
classifyArtifact,
discoverInstallerMigrations,
INSTALL_STATE_NAME,
migrationChecksum,
planInstallerMigrations,
readInstallState,
runInstallerMigrations,
writeInstallState,
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
const firstTimeBaselineMigration = require('../gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs');
function createTempInstall() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-'));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup predates helpers.cjs; name collision prevents import
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 migrationRecord(overrides = {}) {
return {
id: '2026-05-11-remove-old-hook',
title: 'Remove retired hook',
description: 'Remove retired hook',
introducedIn: '1.50.0',
scopes: ['global', 'local'],
destructive: true,
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
ownershipEvidence: 'test fixture manifest-managed hook',
},
],
...overrides,
};
}
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, 'gsd-core/workflows/plan.md', 'managed workflow\n');
writeFile(configDir, 'gsd-core/USER-PROFILE.md', 'user profile\n');
writeManifest(configDir, {
'gsd-core/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, 'gsd-core/workflows/plan.md'), 'utf8'), 'managed workflow\n');
assert.equal(fs.readFileSync(path.join(configDir, 'gsd-core/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: 'gsd-core/workflows/plan.md',
classification: 'managed-pristine',
},
{
type: 'baseline-preserve-user',
relPath: 'gsd-core/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('preserves user-owned skill files during baseline without hashing their content', (t) => {
const configDir = createTempInstall();
const originalOpenSync = fs.openSync;
t.after(() => {
fs.openSync = originalOpenSync;
cleanup(configDir);
});
writeFile(configDir, 'skills/custom-user-skill/SKILL.md', 'user skill\n');
writeManifest(configDir, {});
const userSkillPath = path.join(configDir, 'skills/custom-user-skill/SKILL.md');
fs.openSync = (filePath, ...args) => {
if (path.resolve(String(filePath)) === path.resolve(userSkillPath)) {
throw new Error('user-owned skill content should not be hashed during baseline');
}
return originalOpenSync.call(fs, filePath, ...args);
};
const result = runInstallerMigrations({
configDir,
runtime: 'claude',
scope: 'global',
migrations: [firstTimeBaselineMigration],
baselineScan: true,
now: () => '2026-05-11T00:00:01.000Z',
});
assert.deepEqual(
result.plan.actions.map((action) => ({
type: action.type,
relPath: action.relPath,
classification: action.classification,
currentHash: action.currentHash,
})),
[
{
type: 'baseline-preserve-user',
relPath: 'skills/custom-user-skill/SKILL.md',
classification: 'user-owned',
currentHash: null,
},
]
);
});
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('records known generated agent artifacts so profile cleanup can remove them', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'agents/gsd-executor.md', 'old generated agent\n');
writeFile(configDir, 'agents/gsd-executor.toml', 'old generated agent config\n');
writeFile(configDir, 'agents/gsd-local-experiment.md', 'user experiment\n');
writeManifest(configDir, {});
const result = runInstallerMigrations({
configDir,
runtime: 'codex',
scope: 'global',
migrations: [firstTimeBaselineMigration],
baselineScan: true,
now: () => '2026-05-11T00:00:03.000Z',
});
assert.deepEqual(
result.plan.actions.map((action) => ({
type: action.type,
relPath: action.relPath,
classification: action.classification,
})),
[
{
type: 'record-baseline',
relPath: 'agents/gsd-executor.md',
classification: 'unknown',
},
{
type: 'record-baseline',
relPath: 'agents/gsd-executor.toml',
classification: 'unknown',
},
{
type: 'prompt-user',
relPath: 'agents/gsd-local-experiment.md',
classification: 'stale-gsd-looking',
},
]
);
assert.deepEqual(result.blocked.map((action) => action.relPath), ['agents/gsd-local-experiment.md']);
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.md'), 'utf8'), 'old generated agent\n');
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.toml'), 'utf8'), 'old generated agent config\n');
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-local-experiment.md'), 'utf8'), 'user experiment\n');
} 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: [
migrationRecord(),
],
scope: 'global',
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: [
migrationRecord(),
],
scope: 'global',
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, null);
} 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: [
migrationRecord({
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/custom-user-hook.js',
reason: 'retired hook',
ownershipEvidence: 'test fixture asks to retire a matching hook path',
},
],
}),
],
scope: 'global',
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('fails closed when install state JSON is malformed', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), '{ not json\n', 'utf8');
assert.throws(
() => readInstallState(configDir),
/invalid installer migration state JSON/
);
});
test('computes each migration checksum once per planned migration', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
writeFile(configDir, 'hooks/first.js', 'first hook\n');
writeFile(configDir, 'hooks/second.js', 'second hook\n');
writeManifest(configDir, {
'hooks/first.js': sha256('first hook\n'),
'hooks/second.js': sha256('second hook\n'),
});
let checksumReads = 0;
const migration = {
...migrationRecord({
id: '2026-05-11-remove-two-hooks',
title: 'Remove two retired hooks',
description: 'Remove retired hooks',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/first.js',
reason: 'retired hook',
ownershipEvidence: 'test fixture manifest-managed hook',
},
{
type: 'remove-managed',
relPath: 'hooks/second.js',
reason: 'retired hook',
ownershipEvidence: 'test fixture manifest-managed hook',
},
],
}),
get checksum() {
checksumReads += 1;
return 'sha256:precomputed';
},
};
const plan = planInstallerMigrations({
configDir,
migrations: [migration],
scope: 'global',
});
assert.equal(plan.actions.length, 2);
assert.equal(checksumReads, 1);
});
test('tolerates an applied-migration checksum drift instead of aborting the upgrade', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
// A migration that a prior release recorded as applied under a DIFFERENT body,
// so the stored checksum no longer matches the current computed checksum.
const migration = migrationRecord({ id: '2026-05-11-remove-old-hook' });
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [
{
id: '2026-05-11-remove-old-hook',
appliedAt: '2026-01-01T00:00:00.000Z',
journal: null,
checksum: 'sha256:stale-pre-1-3-0-value',
},
],
});
// Planning must NOT throw, must skip the already-applied migration, and must
// surface the drift on the plan for downstream reconciliation.
let plan;
assert.doesNotThrow(() => {
plan = planInstallerMigrations({
configDir,
migrations: [migration],
scope: 'global',
now: () => '2026-05-11T00:00:00.000Z',
});
});
assert.deepEqual(plan.pendingMigrationIds, []);
assert.equal(plan.actions.length, 0);
assert.ok(Array.isArray(plan.checksumDrift));
const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook');
assert.ok(drift, 'expected checksum drift to be reported for the applied migration');
assert.equal(drift.storedChecksum, 'sha256:stale-pre-1-3-0-value');
assert.match(drift.currentChecksum, /^sha256:/);
assert.notEqual(drift.currentChecksum, drift.storedChecksum);
});
test('classifies large files without loading the whole file through readFileSync', (t) => {
const configDir = createTempInstall();
const originalReadFileSync = fs.readFileSync;
t.after(() => {
fs.readFileSync = originalReadFileSync;
cleanup(configDir);
});
const relPath = 'skills/gsd-large/SKILL.md';
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, Buffer.alloc(1024 * 1024 + 1, 'a'));
fs.readFileSync = (filePath, ...args) => {
if (path.resolve(String(filePath)) === path.resolve(fullPath)) {
throw new Error('large file should be streamed for hashing');
}
return originalReadFileSync.call(fs, filePath, ...args);
};
const artifact = classifyArtifact(configDir, relPath, { files: {} });
assert.equal(artifact.classification, 'unknown');
assert.match(artifact.currentHash, /^[0-9a-f]{64}$/);
});
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: [
migrationRecord(),
],
scope: 'global',
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.match(
result.journalRelPath,
/^gsd-migration-journal\/2026-05-11T00-00-01-000Z-[0-9a-f]+\.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('uses unique journal paths for applies that share a timestamp', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
writeFile(configDir, 'hooks/first.js', 'first hook\n');
writeFile(configDir, 'hooks/second.js', 'second hook\n');
writeManifest(configDir, {
'hooks/first.js': sha256('first hook\n'),
'hooks/second.js': sha256('second hook\n'),
});
const now = () => '2026-05-11T00:00:09.000Z';
const first = applyInstallerMigrationPlan({
configDir,
plan: {
blocked: [],
actions: [{
migrationId: 'first-migration',
migrationChecksum: 'sha256:first',
type: 'remove-managed',
relPath: 'hooks/first.js',
reason: 'first',
classification: 'managed-pristine',
originalHash: sha256('first hook\n'),
currentHash: sha256('first hook\n'),
}],
},
now,
});
const second = applyInstallerMigrationPlan({
configDir,
plan: {
blocked: [],
actions: [{
migrationId: 'second-migration',
migrationChecksum: 'sha256:second',
type: 'remove-managed',
relPath: 'hooks/second.js',
reason: 'second',
classification: 'managed-pristine',
originalHash: sha256('second hook\n'),
currentHash: sha256('second hook\n'),
}],
},
now,
});
assert.notEqual(first.journalRelPath, second.journalRelPath);
assert.equal(fs.existsSync(path.join(configDir, first.journalRelPath)), true);
assert.equal(fs.existsSync(path.join(configDir, second.journalRelPath)), true);
});
test('stores modified-file backups under the unique migration run journal', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
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: [
migrationRecord(),
],
scope: 'global',
now: () => '2026-05-11T00:00:00.000Z',
});
const result = applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:10.000Z',
});
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
const backupRelPath = journal.actions[0].backupRelPath;
assert.match(backupRelPath, /^gsd-migration-journal\/2026-05-11T00-00-10-000Z-[0-9a-f]+-backups\/hooks\/old-hook\.js$/);
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user changed hook\n');
});
test('successful migration rollback removes run-scoped backup directories', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
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: [
migrationRecord(),
],
scope: 'global',
});
const result = applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:11.000Z',
});
result.rollback();
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
assert.equal(
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) => name.includes('backups')),
false
);
});
test('refuses to run migrations while another installer owns the migration lock', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
fs.writeFileSync(path.join(configDir, 'gsd-install-migration.lock'), 'held by test\n', 'utf8');
assert.throws(
() => runInstallerMigrations({
configDir,
migrations: [],
lockTimeoutMs: 0,
}),
/installer migration lock is held/
);
});
test('reports lock release failures after migration work completes', (t) => {
const configDir = createTempInstall();
const originalUnlinkSync = fs.unlinkSync;
t.after(() => {
fs.unlinkSync = originalUnlinkSync;
cleanup(configDir);
});
// The release closure uses fs.unlinkSync (not fs.rmSync) so that EPERM is
// NOT silently swallowed on Windows (#3670). Mock unlinkSync to simulate
// a Windows NTFS EPERM condition when the lock file is removed.
fs.unlinkSync = (targetPath) => {
if (path.basename(String(targetPath)) === 'gsd-install-migration.lock') {
throw new Error('simulated lock unlink failure');
}
return originalUnlinkSync.call(fs, targetPath);
};
assert.throws(
() => runInstallerMigrations({
configDir,
migrations: [],
}),
/failed to release installer migration lock/
);
});
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 {
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')) &&
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
name.startsWith('2026-05-11T00-00-02-000Z')
),
false
);
} finally {
cleanup(configDir);
}
});
test('cleans rollback and backup artifacts when migration apply fails', (t) => {
const configDir = createTempInstall();
t.after(() => cleanup(configDir));
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
const plan = {
blocked: [],
actions: [
{
migrationId: '2026-05-11-remove-old-hook',
migrationChecksum: 'sha256:remove',
type: 'backup-and-remove',
relPath: 'hooks/old-hook.js',
reason: 'retired hook',
classification: 'managed-modified',
originalHash: sha256('managed hook\n'),
currentHash: sha256('user changed hook\n'),
},
{
migrationId: '2026-05-11-remove-old-hook',
migrationChecksum: 'sha256:remove',
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:12.000Z',
}),
/unsupported migration action type/
);
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
assert.equal(
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
name.startsWith('2026-05-11T00-00-12-000Z')
),
false
);
});
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 (/2026-05-11T00-00-04-000Z-[0-9a-f]+-rollback/.test(String(src))) {
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: [
migrationRecord({
plan: () => {
throw new Error('already-applied migration planner must not run');
},
}),
],
scope: 'global',
now: () => '2026-05-11T00:00:03.000Z',
});
assert.deepEqual(plan.pendingMigrationIds, []);
assert.deepEqual(plan.actions, []);
assert.deepEqual(plan.blocked, []);
} finally {
cleanup(configDir);
}
});
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('surfaces checksum drift for an already-applied migration without aborting', () => {
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',
},
],
});
let plan;
assert.doesNotThrow(() => {
plan = planInstallerMigrations({
configDir,
migrations: [
migrationRecord({
checksum: 'sha256:new-definition',
plan: () => [],
}),
],
scope: 'global',
});
});
assert.deepEqual(plan.pendingMigrationIds, []);
assert.ok(Array.isArray(plan.checksumDrift));
const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook');
assert.ok(drift, 'expected drift entry for the applied migration');
assert.equal(drift.storedChecksum, 'sha256:old-definition');
assert.equal(drift.currentChecksum, 'sha256:new-definition');
} 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: [
migrationRecord({
id: '2026-05-11-codex-only',
title: 'Codex-only migration',
description: 'Codex-only migration',
checksum: 'sha256:new-definition',
runtimes: ['codex'],
scopes: ['global'],
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', title: 'Second', description: 'second', introducedIn: '1.50.0', scopes: ['global', 'local'], destructive: false, plan: () => [] };\n",
'utf8'
);
fs.writeFileSync(
path.join(migrationsDir, '001-first.cjs'),
"module.exports = { id: 'first', title: 'First', description: 'first', introducedIn: '1.50.0', scopes: ['global', 'local'], destructive: false, 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: [
migrationRecord({
id: '2026-05-11-bad-path',
title: 'Bad path',
description: 'Bad path',
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/../../outside.js',
reason: 'bad path',
ownershipEvidence: 'test fixture manifest-managed hook',
},
],
}),
],
scope: 'global',
}),
/relPath must stay inside configDir/
);
} finally {
cleanup(configDir);
}
});
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 {
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('backs up modified legacy orphan files before removing them', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/statusline.js', 'user modified legacy hook\n');
writeManifest(configDir, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: discoverInstallerMigrations({
migrationsDir: path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'installer-migrations'),
}),
scope: 'global',
now: () => '2026-05-11T00:00:05.000Z',
});
const action = plan.actions.find((item) => item.relPath === 'hooks/statusline.js');
assert.equal(action.type, 'backup-and-remove');
const result = runInstallerMigrations({
configDir,
scope: 'global',
now: () => '2026-05-11T00:00:05.000Z',
});
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
const backupRelPath = journal.actions.find((item) => item.relPath === 'hooks/statusline.js').backupRelPath;
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user modified legacy hook\n');
} 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('preserves unrelated empty hooks.json structure while pruning legacy Codex hooks', () => {
const configDir = createTempInstall();
try {
writeFile(
configDir,
'hooks.json',
JSON.stringify({
SessionStart: [
legacyCodexHook(configDir),
{ hooks: [] },
{ metadata: null },
],
}, null, 2)
);
writeManifest(configDir, {});
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'));
assert.deepEqual(hooksJson.SessionStart, [
{ hooks: [] },
{ metadata: null },
]);
} 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);
}
});
// ---------------------------------------------------------------------------
// Checksum-baseline guardrail (issue #670)
//
// Shipped installer-migration bodies are immutable: editing a released body
// breaks the stored checksum for any user who has already applied that
// migration, which was the root cause of issue #670.
//
// This test locks every shipped migration to its committed checksum so that CI
// catches accidental body edits. When you INTENTIONALLY change the behaviour
// of a migration you must add a NEW fix-forward migration id instead; if for
// some extraordinary reason you truly need to update an existing baseline, add
// the new checksum here with a comment explaining why.
//
// Mechanism: compute each migration's checksum directly via the exported
// migrationChecksum() (scope-independent) and assert it matches the committed
// baseline. This is simpler and more robust than the previous plan()-based
// approach because it doesn't depend on runtime/scope filtering.
// ---------------------------------------------------------------------------
test('shipped installer-migration checksums are locked to a committed baseline (issue #670 guardrail)', () => {
// Committed baseline — update ONLY when adding a new migration or performing an
// extraordinary intentional body change (add a comment explaining why). Editing a
// shipped migration body breaks the stored checksum for everyone who already applied
// it (root cause of #670) — add a NEW fix-forward migration id instead.
const EXPECTED_CHECKSUMS = {
'2026-05-11-first-time-baseline-scan':
'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a',
'2026-05-11-legacy-orphan-files':
'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c',
'2026-05-11-codex-legacy-hooks-json':
'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058',
'2026-06-02-rename-get-shit-done-to-gsd-core':
'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95',
// Migration 004: prune stale gsd-pristine/get-shit-done/ snapshots (#934) // gsd-allow-legacy-name
'2026-06-09-prune-stale-pristine-get-shit-done': // gsd-allow-legacy-name
'sha256:6555dd044659276fbc204e81793cd92c5315d54e7316bcdd82d2c98d15a7e9e8',
};
const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs');
const migrations = discoverInstallerMigrations({ migrationsDir: DEFAULT_MIGRATIONS_DIR });
const discoveredIds = new Set(migrations.map((m) => m.id));
// No stale baseline entries.
for (const id of Object.keys(EXPECTED_CHECKSUMS)) {
assert.ok(discoveredIds.has(id),
`EXPECTED_CHECKSUMS has a stale entry for '${id}' — that migration no longer exists; remove it`);
}
// Every discovered migration has a committed baseline entry.
for (const id of discoveredIds) {
assert.ok(Object.prototype.hasOwnProperty.call(EXPECTED_CHECKSUMS, id),
`new migration '${id}' has no committed checksum baseline — add it to EXPECTED_CHECKSUMS in tests/installer-migrations.test.cjs`);
}
// Core lock: each shipped migration's current checksum must match its committed baseline,
// computed directly (scope-independent).
for (const m of migrations) {
assert.strictEqual(computeChecksum(m), EXPECTED_CHECKSUMS[m.id],
`'${m.id}' body changed — its checksum drifted from the committed baseline; ` +
`add a NEW fix-forward migration id instead of editing a shipped migration body, ` +
`or intentionally update the baseline in EXPECTED_CHECKSUMS`);
}
});
test('reconciles a drifted applied-migration checksum into install state on apply', () => {
const configDir = createTempInstall();
try {
// Set up: one already-applied migration with a stale checksum, one pending migration
// that will produce an action (so applyInstallerMigrationPlan writes state).
const alreadyAppliedMigration = migrationRecord({
id: '2026-05-11-already-applied-with-drift',
title: 'Already applied with drift',
description: 'Already applied with drift',
scopes: ['global'],
destructive: false,
plan: () => [],
});
const pendingMigration = migrationRecord({
id: '2026-05-11-pending-to-trigger-apply',
title: 'Pending migration',
description: 'Pending migration',
scopes: ['global'],
destructive: true,
plan: () => [
{
type: 'remove-managed',
relPath: 'hooks/old-hook.js',
reason: 'retiring hook',
ownershipEvidence: 'test fixture manifest-managed hook',
},
],
});
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
writeManifest(configDir, {
'hooks/old-hook.js': sha256('managed hook\n'),
});
// Seed install state: alreadyAppliedMigration recorded with a STALE checksum.
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [
{
id: alreadyAppliedMigration.id,
appliedAt: '2026-01-01T00:00:00.000Z',
journal: null,
checksum: 'sha256:stale-old',
},
],
});
const plan = planInstallerMigrations({
configDir,
migrations: [alreadyAppliedMigration, pendingMigration],
scope: 'global',
now: () => '2026-05-11T00:00:00.000Z',
});
// The already-applied migration should appear in checksumDrift.
const drift = plan.checksumDrift.find((d) => d.id === alreadyAppliedMigration.id);
assert.ok(drift, 'expected checksumDrift entry for the already-applied migration');
assert.equal(drift.storedChecksum, 'sha256:stale-old');
// Apply the plan (the pending migration has an action, so this writes state).
applyInstallerMigrationPlan({
configDir,
plan,
now: () => '2026-05-11T00:00:01.000Z',
});
// Re-read install state and assert the stale checksum was reconciled.
const stateAfter = readInstallState(configDir);
const reconciledEntry = stateAfter.appliedMigrations.find(
(entry) => entry.id === alreadyAppliedMigration.id
);
assert.ok(reconciledEntry, 'expected the already-applied entry to still be in install state');
const expectedChecksum = migrationChecksum(alreadyAppliedMigration);
assert.strictEqual(
reconciledEntry.checksum,
expectedChecksum,
`expected checksum to be reconciled to current value (${expectedChecksum}), not the stale 'sha256:stale-old'`
);
assert.notEqual(reconciledEntry.checksum, 'sha256:stale-old',
'stale checksum must not remain after apply');
} finally {
cleanup(configDir);
}
});