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.
345 lines
13 KiB
JavaScript
345 lines
13 KiB
JavaScript
// docs-guard-exempt: docs/installer-migrations.md is cited only in comments as design rationale; never read.
|
|
'use strict';
|
|
|
|
/**
|
|
* Installer migration coverage for
|
|
* 2026-07-28-retire-config-root-commonjs-marker (#2544).
|
|
*
|
|
* #2544 moved MSD's `{"type":"commonjs"}` marker out of the runtime config root
|
|
* and into the directories MSD actually fills. Without this migration an
|
|
* UPGRADED install keeps both markers, so the config root stays pinned to
|
|
* CommonJS and the fix's own claim — that MSD no longer writes the shared
|
|
* config root — is false for every install made before it.
|
|
*
|
|
* The migration is unusual in one respect, and that is what most of this file
|
|
* pins: the config-root marker was never recorded in `msd-file-manifest.json`,
|
|
* so `classifyArtifact` answers `unknown` for it and the planner's own guard
|
|
* downgrades a `remove-managed` on an `unknown` classification to
|
|
* `preserve-user`. The migration therefore supplies the "purpose-built detector
|
|
* for an old MSD-owned shape" that docs/installer-migrations.md#remove-managed
|
|
* sanctions, and DECLARES the resulting classification on the action. If that
|
|
* declaration ever stops being honoured the migration silently does nothing, so
|
|
* the end-to-end planner test below is a negative control, not a formality.
|
|
*
|
|
* Authoring-workflow coverage (docs/installer-migrations.md#authoring-workflow):
|
|
* dry-run plan output ....... "plan() emits remove-managed"
|
|
* apply behaviour ........... "applies through the real planner + executor"
|
|
* locally modified file ..... "a user-authored package.json is never touched"
|
|
* user-owned files nearby ... "leaves sibling files alone"
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
|
|
const migration = require('../msd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs');
|
|
|
|
const {
|
|
classifyArtifact: realClassifyArtifact,
|
|
readInstallManifest,
|
|
planInstallerMigrations,
|
|
applyInstallerMigrationPlan,
|
|
} = require('../msd-core/bin/lib/installer-migrations.cjs');
|
|
|
|
// The shared teardown helper, not a local rmSync: it chdir's out of the target
|
|
// first (Windows cannot remove a directory that is the CWD) and retries
|
|
// 20 x 250ms to absorb the deferred-scan handle Windows Defender holds on
|
|
// newly-written files. This repo runs a windows-latest lane, so both matter.
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const MARKER = '{"type":"commonjs"}';
|
|
const ROOT_REL = 'package.json';
|
|
|
|
/** The shape #2544 was filed about — an OpenCode config-root manifest. */
|
|
const USER_PACKAGE_JSON = JSON.stringify(
|
|
{
|
|
name: 'my-opencode-config',
|
|
type: 'module',
|
|
dependencies: { shescape: '^2.1.0' },
|
|
},
|
|
null,
|
|
2,
|
|
) + '\n';
|
|
|
|
function createTempDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'msd-migration-007-test-'));
|
|
}
|
|
|
|
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.8.0', timestamp: '2026-07-28T00:00:00.000Z', mode: 'full', files },
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
function makePlanCtx(configDir) {
|
|
const manifest = readInstallManifest(configDir);
|
|
return {
|
|
configDir,
|
|
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
|
|
};
|
|
}
|
|
|
|
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('migration 007 metadata', () => {
|
|
test('exports a single migration object with the required authoring fields', () => {
|
|
assert.equal(typeof migration, 'object');
|
|
assert.equal(typeof migration.id, 'string');
|
|
assert.ok(migration.id.length > 0, 'id must be non-empty');
|
|
assert.equal(typeof migration.title, 'string');
|
|
assert.equal(typeof migration.description, 'string');
|
|
assert.equal(typeof migration.introducedIn, 'string');
|
|
assert.ok(Array.isArray(migration.scopes), 'scopes must be an array');
|
|
assert.ok(migration.scopes.includes('global'));
|
|
assert.ok(migration.scopes.includes('local'));
|
|
assert.strictEqual(migration.destructive, true);
|
|
assert.equal(typeof migration.plan, 'function');
|
|
});
|
|
|
|
test('applies to every runtime — the marker was written for all of them', () => {
|
|
// "All runtimes" is expressed by OMITTING `runtimes`, never by `[]`. The two
|
|
// halves of the framework disagree about the empty array and only one of
|
|
// them runs on the record: `validateStringArray` requires the field to be a
|
|
// NON-EMPTY string array when present and throws otherwise, while the
|
|
// runtime filter (`Array.isArray(runtimes) && runtimes.length > 0`) would
|
|
// have treated `[]` as "all". A `runtimes: []` record therefore throws at
|
|
// plan time and the migration never runs at all — which is precisely how
|
|
// this migration was first written.
|
|
assert.equal(migration.runtimes, undefined, 'runtimes must be omitted, not []');
|
|
});
|
|
|
|
test('id carries the expected date prefix and names the retired artifact', () => {
|
|
assert.ok(migration.id.startsWith('2026-07-28-'), `unexpected id: ${migration.id}`);
|
|
assert.match(migration.id, /config-root-commonjs-marker/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. plan() behaviour
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('migration 007 plan()', () => {
|
|
test('emits no actions when the config root has no package.json (fresh install)', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeManifest(dir, {});
|
|
|
|
assert.deepEqual(migration.plan(makePlanCtx(dir)), [], 'no file -> no actions');
|
|
});
|
|
|
|
test('emits remove-managed for the exact legacy marker, with declared ownership', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, `${MARKER}\n`);
|
|
writeManifest(dir, {});
|
|
|
|
const actions = migration.plan(makePlanCtx(dir));
|
|
assert.equal(actions.length, 1);
|
|
const [action] = actions;
|
|
assert.equal(action.type, 'remove-managed');
|
|
assert.equal(action.relPath, ROOT_REL);
|
|
assert.ok(action.ownershipEvidence && action.ownershipEvidence.length > 0);
|
|
// The declared classification IS the ownership proof — see the planner test.
|
|
assert.equal(action.classification, 'managed-pristine');
|
|
assert.equal(action.originalHash, action.currentHash);
|
|
assert.equal(action.currentHash, sha256(`${MARKER}\n`));
|
|
});
|
|
|
|
test('tolerates trailing-whitespace variants of the marker', (t) => {
|
|
for (const content of [MARKER, `${MARKER}\n`, `${MARKER}\n\n`, ` ${MARKER} \n`]) {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, content);
|
|
writeManifest(dir, {});
|
|
assert.equal(
|
|
migration.plan(makePlanCtx(dir)).length,
|
|
1,
|
|
`expected a match for ${JSON.stringify(content)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a user-authored package.json is never touched', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, USER_PACKAGE_JSON);
|
|
writeManifest(dir, {});
|
|
|
|
assert.deepEqual(
|
|
migration.plan(makePlanCtx(dir)),
|
|
[],
|
|
'a package.json that is not exactly the marker must produce no action',
|
|
);
|
|
});
|
|
|
|
test('a marker with any extra key is foreign — no backup-and-remove branch', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
// Semantically "the marker plus something the user added". The exact-content
|
|
// predicate rejects it, and there is deliberately no modified-file branch:
|
|
// this is somebody else's file, not a patched MSD artifact.
|
|
writeFile(dir, ROOT_REL, `${JSON.stringify({ type: 'commonjs', name: 'mine' })}\n`);
|
|
writeManifest(dir, {});
|
|
|
|
assert.deepEqual(migration.plan(makePlanCtx(dir)), []);
|
|
});
|
|
|
|
test('a symlinked package.json is never followed or planned', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
const outside = path.join(dir, 'outside.json');
|
|
fs.writeFileSync(outside, `${MARKER}\n`);
|
|
fs.symlinkSync(outside, path.join(dir, ROOT_REL));
|
|
writeManifest(dir, {});
|
|
|
|
assert.deepEqual(
|
|
migration.plan(makePlanCtx(dir)),
|
|
[],
|
|
'a symlink is not something MSD wrote — never ours to remove',
|
|
);
|
|
assert.ok(fs.existsSync(outside), 'the link target must survive');
|
|
});
|
|
|
|
test('a directory at the marker path is never planned', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, ROOT_REL));
|
|
writeManifest(dir, {});
|
|
|
|
assert.deepEqual(migration.plan(makePlanCtx(dir)), []);
|
|
});
|
|
|
|
test('leaves sibling files in the config root alone', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, `${MARKER}\n`);
|
|
writeFile(dir, 'opencode.json', '{"theme":"mine"}\n');
|
|
writeManifest(dir, {});
|
|
|
|
const actions = migration.plan(makePlanCtx(dir));
|
|
assert.equal(actions.length, 1);
|
|
assert.equal(actions[0].relPath, ROOT_REL, 'only the marker is ever planned');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. End-to-end through the REAL planner + executor
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('migration 007 through the real planner', () => {
|
|
test('the declared classification survives the planner (negative control)', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, `${MARKER}\n`);
|
|
writeManifest(dir, {});
|
|
|
|
// The config-root marker is NOT in the manifest, so the planner's own
|
|
// classify() answers 'unknown' for it...
|
|
const manifest = readInstallManifest(dir);
|
|
assert.equal(
|
|
realClassifyArtifact(dir, ROOT_REL, manifest).classification,
|
|
'unknown',
|
|
'precondition: the marker was never manifest-recorded',
|
|
);
|
|
|
|
// ...and `remove-managed` on an 'unknown' classification is downgraded to
|
|
// `preserve-user`. This assertion is the whole reason the migration declares
|
|
// its own classification: without that declaration the plan below would
|
|
// contain a preserve-user action and the migration would be inert.
|
|
const plan = planInstallerMigrations({
|
|
configDir: dir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [migration],
|
|
});
|
|
|
|
const actions = plan.actions.filter((a) => a.relPath === ROOT_REL);
|
|
assert.equal(actions.length, 1, 'the marker must survive planning as one action');
|
|
assert.equal(
|
|
actions[0].type,
|
|
'remove-managed',
|
|
'declared ownership must NOT be downgraded to preserve-user',
|
|
);
|
|
});
|
|
|
|
test('applying the plan removes the marker and journals a rollback', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, `${MARKER}\n`);
|
|
writeFile(dir, 'opencode.json', '{"theme":"mine"}\n');
|
|
writeManifest(dir, {});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir: dir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [migration],
|
|
});
|
|
applyInstallerMigrationPlan({ configDir: dir, plan, runtime: 'opencode', scope: 'global' });
|
|
|
|
assert.ok(
|
|
!fs.existsSync(path.join(dir, ROOT_REL)),
|
|
'the stale config-root marker must be gone after apply',
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(dir, 'opencode.json')),
|
|
'sibling config files must survive',
|
|
);
|
|
});
|
|
|
|
test('is idempotent — a second run plans nothing', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, `${MARKER}\n`);
|
|
writeManifest(dir, {});
|
|
|
|
const first = planInstallerMigrations({
|
|
configDir: dir, runtime: 'opencode', scope: 'global', migrations: [migration],
|
|
});
|
|
applyInstallerMigrationPlan({ configDir: dir, plan: first, runtime: 'opencode', scope: 'global' });
|
|
|
|
// Re-plan against the post-apply tree. The file is gone, so plan() short-
|
|
// circuits at the lstat and there is nothing left to do.
|
|
assert.deepEqual(migration.plan(makePlanCtx(dir)), []);
|
|
});
|
|
|
|
test('a user-authored package.json survives the full plan+apply cycle', (t) => {
|
|
const dir = createTempDir();
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, ROOT_REL, USER_PACKAGE_JSON);
|
|
writeManifest(dir, {});
|
|
const before = sha256(fs.readFileSync(path.join(dir, ROOT_REL)));
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir: dir, runtime: 'opencode', scope: 'global', migrations: [migration],
|
|
});
|
|
applyInstallerMigrationPlan({ configDir: dir, plan, runtime: 'opencode', scope: 'global' });
|
|
|
|
assert.ok(fs.existsSync(path.join(dir, ROOT_REL)), 'the user file must survive');
|
|
assert.equal(
|
|
sha256(fs.readFileSync(path.join(dir, ROOT_REL))),
|
|
before,
|
|
'the user file must be byte-identical after the migration runs',
|
|
);
|
|
});
|
|
});
|