refactor(#1557): add runtime artifact install plan module (#1560)

This commit is contained in:
Tom Boucher
2026-06-21 20:40:13 -04:00
committed by GitHub
parent a0b169ad50
commit 405ae9b3b7
8 changed files with 395 additions and 0 deletions

View File

@@ -157,6 +157,9 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement
### Runtime Artifact Conversion Module
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`).
### Runtime Artifact Install Plan Module
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
### Command Roster Module
Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.

View File

@@ -368,6 +368,7 @@
"roadmap-upgrade.cjs",
"roadmap.cjs",
"runtime-artifact-conversion.cjs",
"runtime-artifact-install-plan.cjs",
"runtime-artifact-layout.cjs",
"runtime-config-adapter-registry.cjs",
"runtime-homes.cjs",

View File

@@ -476,6 +476,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `runtime-artifact-conversion.cjs` | Runtime artifact conversion module — projects Claude-authored commands, agents, and skills into runtime-specific artifact bodies while preserving installer compatibility exports |
| `runtime-artifact-install-plan.cjs` | Runtime artifact install plan module — stages pre-resolved layout kinds, applies runtime body rewrites, and returns copy-plan items plus cleanup obligations |
| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) |
| `runtime-config-adapter-registry.cjs` | Explicit runtime config adapter registry — resolves per-runtime config-mutation install intent (install surface, shared-settings gate, finish-phase permission writer); see ADR-58. |
| `runtime-hooks-surface.cjs` | Runtime hooks surface module — standalone hook-surface writer functions extracted from bin/install.js (ADR-857 phase 5f-1); owns Cline/Cursor/Copilot/Codex hook artifact generation and reconciliation. |

View File

@@ -112,6 +112,7 @@ export default tseslint.config(
'gsd-core/bin/lib/planning-workspace.cjs',
'gsd-core/bin/lib/command-roster.cjs',
'gsd-core/bin/lib/runtime-artifact-conversion.cjs',
'gsd-core/bin/lib/runtime-artifact-install-plan.cjs',
'gsd-core/bin/lib/runtime-artifact-layout.cjs',
'gsd-core/bin/lib/runtime-config-adapter-registry.cjs',
'gsd-core/bin/lib/runtime-hooks-surface.cjs',

View File

@@ -0,0 +1,69 @@
'use strict';
/**
* Runtime Artifact Install Plan Module.
*
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
* installer adapter still owns pruning, copying, migrations, output, and final
* cleanup execution.
*/
// In .cts (CommonJS output) files, `require` is available as a global.
const _require = require;
const path = _require('node:path');
function errorMessage(err) {
if (err instanceof Error)
return err.message;
return String(err);
}
function addCleanupDir(cleanupDirs, stagedDir, rewrittenDir) {
const sourceDir = rewrittenDir ?? stagedDir;
if (sourceDir !== stagedDir)
cleanupDirs.push(sourceDir);
return sourceDir;
}
function createRuntimeArtifactInstallPlan(args) {
const { layout, resolvedProfile, homedir, platform, resolveAttribution, deps = {}, } = args;
const conversionExports = _require('./runtime-artifact-conversion.cjs');
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
const cleanupDirs = [];
const items = [];
const scope = layout.scope ?? 'global';
const rewriteOpts = {
runtime: layout.runtime,
configDir: layout.configDir,
scope,
homedir,
platform,
resolveAttribution,
};
for (const kind of layout.kinds) {
let stagedDir;
try {
stagedDir = kind.stage(resolvedProfile);
}
catch (err) {
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
let sourceDir = stagedDir;
try {
if (kind.kind === 'commands') {
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
}
catch (err) {
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
items.push({
kind: kind.kind,
sourceDir,
destDir: path.join(layout.configDir, kind.destSubpath),
});
}
return { ok: true, plan: { items, cleanupDirs } };
}
module.exports = { createRuntimeArtifactInstallPlan };

View File

@@ -120,5 +120,8 @@
"test:coverage:all": "npm run test:coverage",
"test:mutation": "stryker run",
"test:mutation:since": "stryker run --incremental --since origin/next"
},
"allowScripts": {
"fallow@2.70.0": true
}
}

View File

@@ -0,0 +1,146 @@
'use strict';
/**
* Runtime Artifact Install Plan Module.
*
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
* installer adapter still owns pruning, copying, migrations, output, and final
* cleanup execution.
*/
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
const path = _require('node:path') as typeof import('node:path');
type ArtifactKindName = 'commands' | 'agents' | 'skills' | 'kimi-agents';
type InstallScope = 'local' | 'global';
interface ResolvedProfile {
name?: string;
skills?: Set<string> | '*';
agents?: Set<string>;
}
interface ArtifactKind {
kind: ArtifactKindName;
destSubpath: string;
stage: (resolvedProfile: ResolvedProfile) => string;
}
interface Layout {
runtime: string;
configDir: string;
scope?: InstallScope;
kinds: ArtifactKind[];
}
interface RewriteOpts {
runtime: string;
configDir: string;
scope: InstallScope;
homedir?: () => string;
platform?: NodeJS.Platform;
resolveAttribution?: (runtime: string) => string | null | undefined;
}
interface Dependencies {
rewriteStagedSkillBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
}
interface RuntimeArtifactConversionExports {
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
}
interface PlanItem {
kind: ArtifactKindName;
sourceDir: string;
destDir: string;
}
interface InstallPlan {
items: PlanItem[];
cleanupDirs: string[];
}
type InstallPlanResult =
| { ok: true; plan: InstallPlan }
| { ok: false; kind: 'stage_failed' | 'rewrite_failed'; message: string; cleanupDirs: string[]; failedKind?: ArtifactKindName };
interface CreateRuntimeArtifactInstallPlanArgs {
layout: Layout;
resolvedProfile: ResolvedProfile;
homedir?: () => string;
platform?: NodeJS.Platform;
resolveAttribution?: (runtime: string) => string | null | undefined;
deps?: Dependencies;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function addCleanupDir(cleanupDirs: string[], stagedDir: string, rewrittenDir: string | void): string {
const sourceDir = rewrittenDir ?? stagedDir;
if (sourceDir !== stagedDir) cleanupDirs.push(sourceDir);
return sourceDir;
}
function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlanArgs): InstallPlanResult {
const {
layout,
resolvedProfile,
homedir,
platform,
resolveAttribution,
deps = {},
} = args;
const conversionExports = _require('./runtime-artifact-conversion.cjs') as RuntimeArtifactConversionExports;
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
const cleanupDirs: string[] = [];
const items: PlanItem[] = [];
const scope = layout.scope ?? 'global';
const rewriteOpts: RewriteOpts = {
runtime: layout.runtime,
configDir: layout.configDir,
scope,
homedir,
platform,
resolveAttribution,
};
for (const kind of layout.kinds) {
let stagedDir: string;
try {
stagedDir = kind.stage(resolvedProfile);
} catch (err) {
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
let sourceDir = stagedDir;
try {
if (kind.kind === 'commands') {
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
} else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
} catch (err) {
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
items.push({
kind: kind.kind,
sourceDir,
destDir: path.join(layout.configDir, kind.destSubpath),
});
}
return { ok: true, plan: { items, cleanupDirs } };
}
export = { createRuntimeArtifactInstallPlan };

View File

@@ -0,0 +1,171 @@
'use strict';
const { test, describe } = 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 { createRuntimeArtifactInstallPlan } = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs');
const { cleanup } = require('./helpers.cjs');
function kind(name, destSubpath, stagedDir, calls) {
return {
kind: name,
destSubpath,
prefix: 'gsd-',
stage: (resolvedProfile) => {
calls.push([name, resolvedProfile.name]);
return stagedDir;
},
};
}
describe('createRuntimeArtifactInstallPlan', () => {
test('stages layout kinds in order and projects rewritten source dirs', () => {
const configDir = path.join(os.tmpdir(), 'gsd-plan-config');
const calls = [];
const rewriteCalls = [];
const layout = {
runtime: 'claude',
configDir,
scope: 'global',
kinds: [
kind('commands', 'commands', '/tmp/staged-commands', calls),
kind('agents', 'agents', '/tmp/staged-agents', calls),
kind('skills', 'skills', '/tmp/staged-skills', calls),
kind('kimi-agents', 'agents', '/tmp/staged-kimi-agents', calls),
],
};
const result = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile: { name: 'core' },
deps: {
rewriteStagedSkillBodies: (stagedDir, opts) => {
rewriteCalls.push(['skills', stagedDir, opts.runtime, opts.configDir, opts.scope]);
return stagedDir;
},
rewriteStagedCommandBodies: (stagedDir, opts) => {
rewriteCalls.push(['commands', stagedDir, opts.runtime, opts.configDir, opts.scope]);
return `${stagedDir}-rewritten`;
},
},
});
assert.deepStrictEqual(calls, [
['commands', 'core'],
['agents', 'core'],
['skills', 'core'],
['kimi-agents', 'core'],
]);
assert.deepStrictEqual(rewriteCalls, [
['commands', '/tmp/staged-commands', 'claude', configDir, 'global'],
['skills', '/tmp/staged-skills', 'claude', configDir, 'global'],
['skills', '/tmp/staged-kimi-agents', 'claude', configDir, 'global'],
]);
assert.deepStrictEqual(result, {
ok: true,
plan: {
cleanupDirs: ['/tmp/staged-commands-rewritten'],
items: [
{ kind: 'commands', sourceDir: '/tmp/staged-commands-rewritten', destDir: path.join(configDir, 'commands') },
{ kind: 'agents', sourceDir: '/tmp/staged-agents', destDir: path.join(configDir, 'agents') },
{ kind: 'skills', sourceDir: '/tmp/staged-skills', destDir: path.join(configDir, 'skills') },
{ kind: 'kimi-agents', sourceDir: '/tmp/staged-kimi-agents', destDir: path.join(configDir, 'agents') },
],
},
});
});
test('returns stage_failed when a layout kind stage adapter throws', () => {
const configDir = path.join(os.tmpdir(), 'gsd-plan-config');
const layout = {
runtime: 'claude',
configDir,
scope: 'global',
kinds: [
{
kind: 'skills',
destSubpath: 'skills',
stage: () => { throw new Error('stage boom'); },
},
],
};
const result = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile: { name: 'core' },
deps: {
rewriteStagedSkillBodies: () => { throw new Error('must not rewrite after stage failure'); },
},
});
assert.strictEqual(result.ok, false);
assert.strictEqual(result.kind, 'stage_failed');
assert.strictEqual(result.failedKind, 'skills');
assert.strictEqual(result.message, 'stage boom');
assert.deepStrictEqual(result.cleanupDirs, []);
});
test('returns rewrite_failed with prior cleanup obligations when conversion throws', () => {
const configDir = path.join(os.tmpdir(), 'gsd-plan-config');
const calls = [];
const layout = {
runtime: 'claude',
configDir,
scope: 'global',
kinds: [
kind('commands', 'commands', '/tmp/staged-commands', calls),
kind('skills', 'skills', '/tmp/staged-skills', calls),
],
};
const result = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile: { name: 'core' },
deps: {
rewriteStagedCommandBodies: (stagedDir) => `${stagedDir}-rewritten`,
rewriteStagedSkillBodies: () => { throw new Error('rewrite boom'); },
},
});
assert.strictEqual(result.ok, false);
assert.strictEqual(result.kind, 'rewrite_failed');
assert.strictEqual(result.failedKind, 'skills');
assert.strictEqual(result.message, 'rewrite boom');
assert.deepStrictEqual(result.cleanupDirs, ['/tmp/staged-commands-rewritten']);
});
test('uses real command rewrite seam by default', (t) => {
const stagedCommands = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-plan-commands-'));
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-plan-config-'));
t.after(() => {
cleanup(stagedCommands);
cleanup(configDir);
});
fs.writeFileSync(path.join(stagedCommands, 'help.md'), '# help\n');
const layout = {
runtime: 'claude',
configDir,
scope: 'global',
kinds: [kind('commands', 'commands', stagedCommands, [])],
};
const result = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile: { name: 'core' },
resolveAttribution: () => undefined,
homedir: () => '/Users/example',
platform: 'linux',
});
assert.strictEqual(result.ok, true);
assert.strictEqual(result.plan.items.length, 1);
assert.strictEqual(result.plan.items[0].kind, 'commands');
assert.notStrictEqual(result.plan.items[0].sourceDir, stagedCommands);
assert.ok(fs.existsSync(path.join(result.plan.items[0].sourceDir, 'help.md')));
assert.deepStrictEqual(result.plan.cleanupDirs, [result.plan.items[0].sourceDir]);
for (const dir of result.plan.cleanupDirs) cleanup(dir);
});
});