Files
msd-core/tests/installer-migration-antigravity-retire-confighome-artifacts.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

297 lines
12 KiB
JavaScript

'use strict';
/**
* TDD tests for installer migration 010:
* 2026-08-26-antigravity-retire-confighome-artifacts (#3738)
*
* Antigravity scans ~/.gemini/config/{skills,agents} for machine-local
* discovery; the configHome (~/.gemini/antigravity{,-ide,-cli}) is not
* scanned, so pre-#3738 installs placed skills and agents where the runtime
* silently ignored them. Since #3738 the global layout installs both kinds
* under the .gemini/config home override. This migration converges an
* existing install: managed files under the configHome's skills/ and agents/
* are removed (or backed up if locally modified), unmanifested files are
* preserved, and now-empty container directories are retired. GLOBAL scope
* only — the local .agents workspace surface is live and must be untouched.
*
* Coverage:
* 1. only manifested, unmodified skills/ + agents/ -> both trees gone
* 2. a manifested skill locally modified -> backed up, not silently deleted
* 3. an unmanifested user agent (non-msd AND msd-prefixed) -> file AND parent directory preserved
* 4. a non-msd-prefixed entry under skills/ -> untouched, skills/ preserved
* 5. claude install with a populated skills/ -> completely untouched (independence)
* 6. local scope -> no actions at all
* 7. running the migration twice on case 1 -> second run is a clean no-op
* 8. a symlink under skills/ pointing outside configDir -> not followed, not deleted through
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const migration = require('../msd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs');
const {
classifyArtifact: realClassifyArtifact,
readInstallManifest,
planInstallerMigrations,
applyInstallerMigrationPlan,
} = require('../msd-core/bin/lib/installer-migrations.cjs');
const { cleanup, createTempDir } = require('./helpers.cjs');
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, 'msd-file-manifest.json'),
JSON.stringify(
{
version: '1.11.0',
timestamp: '2026-08-26T00:00:00.000Z',
mode: 'full',
files,
},
null,
2,
),
'utf8',
);
}
function hashOf(root, relPath) {
return crypto.createHash('sha256').update(fs.readFileSync(path.join(root, relPath))).digest('hex');
}
function makePlanCtx(configDir, runtime = 'antigravity', scope = 'global') {
const manifest = readInstallManifest(configDir);
return {
configDir,
runtime,
scope,
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
};
}
function runFullMigration(configDir, runtime = 'antigravity', scope = 'global') {
const plan = planInstallerMigrations({
configDir,
runtime,
scope,
migrations: [migration],
});
assert.deepEqual(plan.blocked, [], 'no action should ever require a prompt or be blocked as unknown');
if (plan.actions.length === 0) return plan;
applyInstallerMigrationPlan({ configDir, plan });
return plan;
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
describe('migration 010 metadata', () => {
test('exports a single migration object with the required authoring fields', () => {
assert.equal(typeof migration, 'object');
assert.equal(typeof migration.id, 'string');
assert.equal(migration.id, '2026-08-26-antigravity-retire-confighome-artifacts');
assert.equal(typeof migration.title, 'string');
assert.equal(typeof migration.description, 'string');
assert.equal(typeof migration.introducedIn, 'string');
assert.deepEqual(migration.runtimes, ['antigravity']);
assert.deepEqual(migration.scopes, ['global']);
assert.strictEqual(migration.destructive, true);
assert.equal(typeof migration.plan, 'function');
});
});
// ---------------------------------------------------------------------------
// 1. Only manifested, unmodified files -> both trees gone
// ---------------------------------------------------------------------------
describe('migration 010: fully managed configHome skills/ + agents/', () => {
test('removes manifested unmodified files and then the emptied directories', (t) => {
const dir = createTempDir('msd-migration-010-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/msd-help/SKILL.md', '# help\n');
writeFile(dir, 'skills/msd-plan/refs/a.md', '# a\n');
writeFile(dir, 'agents/msd-executor.md', '# executor\n');
writeManifest(dir, {
'skills/msd-help/SKILL.md': hashOf(dir, 'skills/msd-help/SKILL.md'),
'skills/msd-plan/refs/a.md': hashOf(dir, 'skills/msd-plan/refs/a.md'),
'agents/msd-executor.md': hashOf(dir, 'agents/msd-executor.md'),
});
const plan = runFullMigration(dir);
assert.ok(plan.actions.some(a => a.type === 'remove-managed' && a.relPath === 'skills/msd-help/SKILL.md'));
assert.ok(plan.actions.some(a => a.type === 'remove-managed' && a.relPath === 'agents/msd-executor.md'));
assert.ok(!fs.existsSync(path.join(dir, 'skills')), 'emptied skills/ directory must be removed');
assert.ok(!fs.existsSync(path.join(dir, 'agents')), 'emptied agents/ directory must be removed');
});
test('second run on the converged install is a clean no-op', (t) => {
const dir = createTempDir('msd-migration-010-idempotent-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/msd-help/SKILL.md', '# help\n');
writeManifest(dir, {
'skills/msd-help/SKILL.md': hashOf(dir, 'skills/msd-help/SKILL.md'),
});
runFullMigration(dir);
const plan = runFullMigration(dir);
assert.deepEqual(plan.actions, [], 'nothing left to retire');
});
});
// ---------------------------------------------------------------------------
// 2. A manifested file locally modified -> backed up, not silently deleted
// ---------------------------------------------------------------------------
describe('migration 010: locally modified manifested skill', () => {
test('backs up the modified file before removing it', (t) => {
const dir = createTempDir('msd-migration-010-modified-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/msd-help/SKILL.md', '# user-patched managed skill\n');
// Manifest records a DIFFERENT hash -> managed-modified.
writeManifest(dir, { 'skills/msd-help/SKILL.md': 'a'.repeat(64) });
const plan = planInstallerMigrations({ configDir: dir, runtime: 'antigravity', scope: 'global', migrations: [migration] });
const fileAction = plan.actions.find((a) => a.relPath === 'skills/msd-help/SKILL.md');
assert.ok(fileAction, 'expected an action for the modified file');
assert.equal(fileAction.type, 'backup-and-remove');
const result = applyInstallerMigrationPlan({ configDir: dir, plan });
assert.equal(fs.existsSync(path.join(dir, 'skills/msd-help/SKILL.md')), false, 'the live modified copy is removed');
const journal = JSON.parse(fs.readFileSync(path.join(dir, result.journalRelPath), 'utf8'));
const journaledFileAction = journal.actions.find((a) => a.relPath === 'skills/msd-help/SKILL.md');
assert.ok(journaledFileAction, 'expected the file action in the journal');
assert.ok(journaledFileAction.backupRelPath, 'expected a recorded backup path');
assert.equal(
fs.existsSync(path.join(dir, journaledFileAction.backupRelPath)),
true,
'the modified file must be recoverable from its backup',
);
assert.equal(
fs.readFileSync(path.join(dir, journaledFileAction.backupRelPath), 'utf8'),
'# user-patched managed skill\n',
);
});
});
// ---------------------------------------------------------------------------
// 3. Unmanifested user files -> preserved, parent dir preserved
// ---------------------------------------------------------------------------
describe('migration 010: unmanifested files under retired surfaces', () => {
test('preserves an unmanifested msd-prefixed user agent and the agents/ dir', (t) => {
const dir = createTempDir('msd-migration-010-unknown-');
t.after(() => cleanup(dir));
writeFile(dir, 'agents/msd-my-own.md', '# user-written\n');
writeManifest(dir, {});
const plan = runFullMigration(dir);
assert.ok(fs.existsSync(path.join(dir, 'agents/msd-my-own.md')), 'unmanifested file preserved');
assert.ok(fs.existsSync(path.join(dir, 'agents')), 'parent dir preserved while it holds a user file');
assert.ok(!plan.actions.some(a => a.relPath === 'agents/msd-my-own.md'), 'no action planned against an unknown file');
});
});
// ---------------------------------------------------------------------------
// 4. Non-msd entries untouched
// ---------------------------------------------------------------------------
describe('migration 010: non-msd entries under skills/', () => {
test('never walks or removes non-msd-prefixed entries', (t) => {
const dir = createTempDir('msd-migration-010-nonmsd-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/user-skill/SKILL.md', '# user skill\n');
writeManifest(dir, {
'skills/user-skill/SKILL.md': hashOf(dir, 'skills/user-skill/SKILL.md'),
});
runFullMigration(dir);
assert.ok(fs.existsSync(path.join(dir, 'skills/user-skill/SKILL.md')), 'non-msd entry untouched even when manifested');
assert.ok(fs.existsSync(path.join(dir, 'skills')), 'skills/ preserved while it holds a non-msd entry');
});
});
// ---------------------------------------------------------------------------
// 5. Independence: another runtime is never touched
// ---------------------------------------------------------------------------
describe('migration 010: runtime independence', () => {
test('a claude configDir with a populated skills/ tree is completely untouched', (t) => {
const dir = createTempDir('msd-migration-010-claude-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/msd-help/SKILL.md', '# help\n');
writeFile(dir, 'agents/msd-executor.md', '# executor\n');
writeManifest(dir, {
'skills/msd-help/SKILL.md': hashOf(dir, 'skills/msd-help/SKILL.md'),
'agents/msd-executor.md': hashOf(dir, 'agents/msd-executor.md'),
});
const plan = runFullMigration(dir, 'claude');
assert.deepEqual(plan.actions, [], 'claude never produces retirement actions');
assert.ok(fs.existsSync(path.join(dir, 'skills/msd-help/SKILL.md')));
assert.ok(fs.existsSync(path.join(dir, 'agents/msd-executor.md')));
});
});
// ---------------------------------------------------------------------------
// 6. Local scope -> no actions (the .agents workspace surface is live)
// ---------------------------------------------------------------------------
describe('migration 010: local scope is out of scope', () => {
test('plans no actions for a local-scope install even with msd artifacts present', (t) => {
const dir = createTempDir('msd-migration-010-local-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/msd-help/SKILL.md', '# help\n');
writeManifest(dir, {
'skills/msd-help/SKILL.md': hashOf(dir, 'skills/msd-help/SKILL.md'),
});
const ctx = makePlanCtx(dir, 'antigravity', 'local');
assert.deepEqual(migration.plan(ctx), [], 'local scope must never retire these surfaces');
});
});
// ---------------------------------------------------------------------------
// 7. Symlink escape is not followed
// ---------------------------------------------------------------------------
describe('migration 010: symlinked entries are not followed', () => {
test('a symlinked msd- dir under skills/ pointing outside configDir is not traversed', (t) => {
const outside = createTempDir('msd-migration-010-outside-');
t.after(() => cleanup(outside));
const dir = createTempDir('msd-migration-010-symlink-');
t.after(() => cleanup(dir));
writeFile(outside, 'SKILL.md', '# outside\n');
fs.mkdirSync(path.join(dir, 'skills'), { recursive: true });
fs.symlinkSync(outside, path.join(dir, 'skills/msd-link'));
const ctx = makePlanCtx(dir);
const actions = migration.plan(ctx);
assert.ok(!actions.some(a => a.relPath.includes('msd-link')), 'no action planned through the symlink');
assert.ok(fs.existsSync(outside), 'the symlink target is untouched');
});
});