From 405ae9b3b7b5b6799d095f9e92bc143ebc6dc547 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 21 Jun 2026 20:40:13 -0400 Subject: [PATCH] refactor(#1557): add runtime artifact install plan module (#1560) --- CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 1 + .../bin/lib/runtime-artifact-install-plan.cjs | 69 +++++++ package.json | 3 + src/runtime-artifact-install-plan.cts | 146 +++++++++++++++ tests/runtime-artifact-install-plan.test.cjs | 171 ++++++++++++++++++ 8 files changed, 395 insertions(+) create mode 100644 gsd-core/bin/lib/runtime-artifact-install-plan.cjs create mode 100644 src/runtime-artifact-install-plan.cts create mode 100644 tests/runtime-artifact-install-plan.test.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 226ea9a41..2f9e9c495 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 3a0826733..948c80c40 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 8d22872ed..1f5980449 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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. | diff --git a/eslint.config.mjs b/eslint.config.mjs index af970ae94..2239c5ba9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs new file mode 100644 index 000000000..b4178e9ca --- /dev/null +++ b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs @@ -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 }; diff --git a/package.json b/package.json index 27614385a..a358cdc17 100644 --- a/package.json +++ b/package.json @@ -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 } } diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts new file mode 100644 index 000000000..4c0ef08bd --- /dev/null +++ b/src/runtime-artifact-install-plan.cts @@ -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 | '*'; + agents?: Set; +} + +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 }; diff --git a/tests/runtime-artifact-install-plan.test.cjs b/tests/runtime-artifact-install-plan.test.cjs new file mode 100644 index 000000000..ea95db252 --- /dev/null +++ b/tests/runtime-artifact-install-plan.test.cjs @@ -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); + }); +});