* fix(#2875): stage user artifacts durably across install wipes (#1874-F19) preserveUserArtifacts held user files only in an in-memory Map across the wipe, so any process death between preserve and restore lost them outright. Seven call sites, not the four the issue records. Three of them never called the helper at all - they open-coded the same read/wipe/write - so searching for callers under-counted by construction; the extra sites were found by sweeping for the pattern instead. The worst is the mainline install path, where the crash window spans the entire gsd-core tree copy rather than a single rmSync. Adds src/user-artifact-staging.cts: durable on-disk staging with a record written after the copies land as the commit point, plus recovery of orphaned batches on the next run - without recovery the staged bytes survive but the user's file is still gone, which would pass its own test while delivering nothing. Routes copyPreservingSymlink through installFs() so staging cannot bypass the install fs seam, and reunites its symlink-safety docblock with the function it documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-3574 with four claims disproved by implementation Implementing Phase 6 disproved four statements the ADR rests on. The central decision - no single materializer - is unaffected and stands. Corrected: decision 3 was already satisfied, so nothing was extracted; the agents-bypass runtime set omitted claude, kilo and opencode, and closing it needed three new pieces of descriptor contract rather than proceeding on its own terms; three of the four blockers the layout comment names were already stale; and F19 is seven call sites, not four. Records the generalizable lesson: the defect is the pattern of holding user data in memory across a wipe, not the helper, so searching for callers of the helper under-counts by construction. Also resolves the ADR's open question on USER_OWNED_ARTIFACTS membership, and notes that copyPreservingSymlink needed routing through the install fs seam before it could be reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close dangling-symlink blind spot and harden staging recovery An adversarial review found the F19 staging work shipped red and unsafe. Root cause, shared by two arbitrary-write findings: hasExistingSymlinkBetween missed dangling symlinks in both its root check and its per-segment walk, because it probed with existsSync, which is false for a link whose target does not exist. Fixing only the new module would have reused a guard that was itself blind. This guard protects the whole install tree. Recovery no longer throws: it degrades per entry and per file, so one bad batch cannot block the others. Previously an unrecoverable entry propagated out of the first statement of install and uninstall, before the cleanup that would have removed it - wedging the installer permanently. Partial fs adapters now throw on any omitted method instead of silently reaching the real filesystem, closing the trap that let a test poison list pass while real IO happened. Staged names must be flat, recovery refuses a dangling destination symlink, and a batch whose recovery genuinely failed is no longer swept - it was discarding the only durable copy of the file it had just failed to restore. Replaces three tests that could not fail, including the one labelled negative proof. Known limitation, documented not closed: concurrent installs sharing a staging key can still lose a batch. A real fix needs a cross-process lock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enh(#2875): make the descriptor authoritative for the agents kind Deletes the inline agent-staging loop in bin/install.js and the _DESCRIPTOR_AGENTS_RUNTIMES set, so every runtime materializes agents from its capability descriptor instead of an inline hostBehaviors dispatch. Closing it needed three pieces of contract the descriptor pipeline never had, all reducible to one missing input - per-agent resolution context: a frontmatter-extensions step for claude's effort and disallowedTools, per-agent model-override resolution for kilo and opencode, and a named branding converter for hermes, whose rewrite data was already declared. Seven runtimes were on the loop, not the six the design recorded - kimi-code was found by a golden fixture, not by analysis. claude-local and kimi-code both silently lost their agents mid-change; the fixtures caught both and the cause was fixed rather than the fixtures regenerated. A parity harness gates the migration: both pipelines over identical inputs, byte-identical output including filenames, per runtime. It is demonstrated red before being trusted. Surface and install paths converge for all seven, which also fixes surface previously writing no agents for these runtimes. Codex's config.toml strip stays put - it mutates host config, which no descriptor kind models. Also routes install-model-override-resolver and install-effort-resolver through the install fs seam. Both leaked real filesystem IO from the install call tree; the stricter adapter is what exposed them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): record the agents-descriptor migration and correct the ADR count The _DESCRIPTOR_AGENTS_RUNTIMES allow-list no longer exists, so the host integration guide told readers to join a set that is gone. Replaces that with what is now true - declare an agents entry and it installs, on the surface path as well as install - and points anyone needing a per-agent transform at the three extension points rather than at a new inline branch. Corrects the ADR amendment: seven runtimes were on the inline loop, not six. kimi-code was found by a golden fixture going red, not by reading. That is the third short count this phase, all from enumerating by symbol or set membership when the thing that matters is a behavior. Adds the Changed changeset for the surface-path convergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-2866 - claude global always wrote agents on disk The claude row's global=[skills] described what capability.json declared, not what the installer wrote. bin/install.js's inline agent-staging loop was never scope-gated and never consulted the descriptor, so a claude --global install has always written agents/gsd-*.md. Phase 6 closes the gap by deleting that loop and declaring agents on claude's descriptor at global scope. On-disk bytes are unchanged - the golden fixtures did not move, which is the evidence that the descriptor, not the installer, was incomplete. #2218 is unaffected: agents are not trigger-bearing, so the wider row does not introduce a new shadowing case. Records the warning that an incomplete descriptor is invisible while a second code path silently does its work, and only surfaces when the two are forced into agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close review findings across staging, agents and the parity harness Two independent reviews of this branch found defects the local gates missed. Security: a dangling symlink at a migration destination allowed writing outside configDir - the same class this change claimed to close, missed at the terminal write of the flow being added. The staging-root resolver threw as the first statement of install and uninstall, so a hostile symlink bricked both, and symlinked-configDir users lost uninstall as well as install; it now degrades instead of aborting. Recovery gained a source-side symlink check and now refuses a relative destDir, which resolved against cwd. Converter dispatch gained a runtime allowlist - lint-time validation stopped mattering once this branch promoted that dispatch from the surface path to real installs. Correctness: claude --local --minimal exited 1 because the minimal profile legitimately yields zero agents and the new path treated that as a failure. cline --local silently lost its agents - its descriptor declared none while the deleted loop wrote them unconditionally. The agents prune was widened to any gsd-* entry and destroyed user files it never owned. The parity harness, on which the migration's safety argument rested, drove a synthetic registry and never byte-compared the shipped descriptors; two of its trap rows could not fail. It now drives the real registry across 13 runtime-scope rows including kimi-code and cline-local, and its red-proof is demonstrated by corrupting a live capability.json. Three goldens that had encoded the cline regression as expected behavior were corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close findings from both mandated review engines /security-review found the staging source-side walk honouring GSD_ALLOW_SYMLINKED_DEST, an opt-in documented as relaxing only the write destination. A symlinked files/ component dereferenced because copyPreservingSymlink lstats the leaf only, so an intermediate link is followed. The source walk no longer honours the opt-in; the destination check still does. /code-review spec axis found this branch had reintroduced its own bug: migrateLegacyDevPreferencesToSkill's new symlink refusal threw unguarded after the legacy dir was wiped and before the staged batch was restored, so a planted symlink bricked uninstall permanently and orphaned the batch. Refusal kept, abort removed. kimi-code local silently lost its agents, the same class as the cline bug, and the parity harness recorded that exclusion as intentional - the third test in this branch to pin a regression as correct. --minimal now creates an empty agents/ dir that never existed. Behaviour restored rather than softening the changeset, so its byte-identical claim stays true. Standards axis: try/finally removed from twelve test bodies, fast-check properties added for parseOwnerPid, boundary coverage at the grace window and the ancestor-probe depth, a parity assertion for the staging-root helper duplicated across two files, and the 8-deep config walk deduplicated. Records 60-review.json with every finding and disposition from five passes, including the smells left unfixed and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): prune stale agents unconditionally in minimal mode The previous round stopped an empty agents/ directory being created when the resolved profile yields no agents. That was implemented by skipping the agents kind entirely, which also skipped its stale-agent prune - so a full to minimal downgrade left stale gsd-* agents behind. The deleted inline loop pruned unconditionally and only skipped writing. Those are three separate conditions, not one: prune always, write only when there is something to write, create the directory only when writing. Both call sites now run _removeGsdEntries before the empty-staged early exit. The symlink-escape guard moved with it, since the prune also touches dest. Codex .toml agents and the config.toml stanzas are cleaned again, and user-owned agents are still preserved. The agents/ directory is left in place after a prune empties it, matching every sibling kind - none of them remove the destination directory itself. Golden fixtures confirmed byte-identical: the prune is a no-op on a fresh install, so fixture generation is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): document interrupted-install recovery for user-owned files The durable-staging fix is invisible to the user it protects. Someone whose install died mid-flight has no way to know USER-PROFILE.md was staged before the delete, that the next run restores it, or that recovery happens at the start of that run rather than in the background. Written as the task the user has - finish the interrupted command - rather than as a description of the mechanism, and states what it will not do: overwrite a file already present, or touch staging belonging to another install still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2875): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2875): assert the J8 model override without building a regex CodeQL flagged incomplete string escaping: the assertion interpolated the override value into a RegExp while escaping only forward slashes, which is meaningless in a constructor, leaving real metacharacters unescaped. The failure direction was the dangerous one - a metacharacter would have made the match more permissive, so the row would pass when it should fail. That matters here because J8 exists precisely because an earlier revision was a tautology; the rewrite reintroduced a different way for the same assertion to stop discriminating. Replaced with a line-wise exact match, so no regex is constructed at all. Swept the other test files this branch adds; no sibling instances. lint:ci passed on the original - lint-no-adhoc-regex-escape matches a full metachar-escape copy, so a single slash replace slipped under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1140 lines
54 KiB
JavaScript
1140 lines
54 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Consolidated tests for the Runtime Artifact Layout Module (ADR-3660) — layout seam.
|
|
*
|
|
* Covers:
|
|
* - resolveRuntimeArtifactLayout — structural shape per runtime
|
|
* - resolveRuntimeArtifactLayout edge-cases (error paths, invalid input)
|
|
* - kind.stage() invocations per kind type
|
|
*
|
|
* Sources consolidated (3 files deleted):
|
|
* tests/runtime-artifact-layout-resolve.test.cjs
|
|
* tests/runtime-artifact-layout-edge-cases.test.cjs
|
|
* tests/runtime-artifact-layout-stage.test.cjs
|
|
*
|
|
* See also:
|
|
* runtime-artifact-layout-surface.test.cjs — surface seam
|
|
* runtime-artifact-layout-install-profiles.test.cjs — install-profiles seam
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs');
|
|
const { install } = require('../bin/install.js');
|
|
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
const FAKE_DIR = '/tmp/fake-config-dir';
|
|
|
|
// ─── resolveRuntimeArtifactLayout — structural shape ────────────────────────
|
|
|
|
describe('resolveRuntimeArtifactLayout — claude local', () => {
|
|
test('returns correct layout for claude scope=local', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local');
|
|
assert.strictEqual(layout.runtime, 'claude');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); // #1367: flat gsd-<cmd>.md layout
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — claude global', () => {
|
|
// #2875 Part 2: claude/global gained an `agents` kind — NOT new on-disk
|
|
// behavior. A `claude --global` install always wrote
|
|
// `<configDir>/agents/gsd-*.md` via the now-deleted inline agent-staging
|
|
// loop in bin/install.js (claude was never in the deleted
|
|
// `_DESCRIPTOR_AGENTS_RUNTIMES` set, and that loop ran unconditionally,
|
|
// independent of `_isSkillsRuntime`/scope). The descriptor previously never
|
|
// modeled that write; it now does, matching what was always materialized —
|
|
// which is also why the golden install-tree fixtures never moved (see
|
|
// tests/fixtures/install-tree/**): the on-disk bytes were unchanged, only
|
|
// the descriptor's own metadata became complete.
|
|
test('returns correct layout for claude scope=global', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global');
|
|
assert.strictEqual(layout.runtime, 'claude');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — cursor', () => {
|
|
test('returns correct layout for cursor — skills + agents only (#2644)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'cursor');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'must have a skills kind');
|
|
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
|
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skillsKind.stage, 'function');
|
|
|
|
assert.equal(layout.kinds.find(k => k.kind === 'commands'), undefined,
|
|
'Cursor skills are the sole slash-menu surface; commands would duplicate them (#2644)');
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — codex', () => {
|
|
// #2875 Part 2: agents kind added — codex was never in the deleted
|
|
// `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote codex agents
|
|
// too; the descriptor now models it. Codex's separate config.toml
|
|
// `[agents.gsd-*]` strip on a full→minimal downgrade is untouched.
|
|
test('returns correct layout for codex', () => {
|
|
const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'codex');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
|
|
test('#2429: codex local scope does not set $HOME/.agents skills home override', () => {
|
|
const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR, 'local');
|
|
const skills = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skills, 'codex local must have a skills kind');
|
|
assert.strictEqual(skills.home, undefined,
|
|
'codex local skills must NOT have a home override (would redirect to $HOME/.agents instead of project-local)');
|
|
});
|
|
|
|
test('#2429: codex global scope DOES set $HOME/.agents skills home override', () => {
|
|
const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR, 'global');
|
|
const skills = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skills, 'codex global must have a skills kind');
|
|
assert.ok(typeof skills.home === 'string' && skills.home.length > 0,
|
|
'codex global skills MUST have a home override ($HOME/.agents)');
|
|
assert.ok(skills.home.includes('.agents'),
|
|
'codex global skills home should point to .agents directory');
|
|
});
|
|
});
|
|
|
|
test('keeps every built-in local artifact layout project-scoped (#2777)', () => {
|
|
for (const [runtime, descriptor] of Object.entries(capabilityRegistry.runtimes)) {
|
|
const localEntries = descriptor.runtime?.artifactLayout?.local ?? [];
|
|
for (const entry of localEntries) {
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(entry, 'home'),
|
|
false,
|
|
`${runtime} local artifact layout entry '${entry.kind}' must not declare home`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — copilot', () => {
|
|
test('returns correct layout for copilot', () => {
|
|
const layout = resolveRuntimeArtifactLayout('copilot', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'copilot');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
// #1575: agents kind added (copilot cutover)
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — antigravity', () => {
|
|
test('returns correct layout for antigravity', () => {
|
|
const layout = resolveRuntimeArtifactLayout('antigravity', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'antigravity');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
// #1575: agents kind added (antigravity cutover)
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — windsurf', () => {
|
|
test('returns local workflow + agents layout for windsurf (ADR-1235)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'local');
|
|
assert.strictEqual(layout.runtime, 'windsurf');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
|
|
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
|
assert.ok(commandsKind, 'must have a commands/workflows kind');
|
|
assert.strictEqual(commandsKind.destSubpath, 'workflows');
|
|
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof commandsKind.stage, 'function');
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
|
|
test('returns agents-only global layout for windsurf (ADR-1235)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'global');
|
|
assert.strictEqual(layout.runtime, 'windsurf');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 1);
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'global windsurf must have agents kind (ADR-1235 §1)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — augment', () => {
|
|
test('returns correct layout for augment (commands + skills + agents — ADR-1235)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'augment');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 3);
|
|
|
|
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
|
assert.ok(commandsKind, 'must have a commands kind');
|
|
assert.strictEqual(commandsKind.destSubpath, 'commands');
|
|
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof commandsKind.stage, 'function');
|
|
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'must have a skills kind');
|
|
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
|
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skillsKind.stage, 'function');
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — trae', () => {
|
|
test('returns correct layout for trae (skills + agents — ADR-1235)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('trae', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'trae');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'must have a skills kind');
|
|
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
|
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skillsKind.stage, 'function');
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — qwen', () => {
|
|
test('returns correct layout for qwen (skills + agents — #2092 Phase B Upgrade 1)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('qwen', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'qwen');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'must have a skills kind');
|
|
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
|
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skillsKind.stage, 'function');
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'must have an agents kind (#2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — kimi', () => {
|
|
test('returns global skills layout and guarded empty local layout for kimi', () => {
|
|
const globalLayout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'global');
|
|
assert.strictEqual(globalLayout.runtime, 'kimi');
|
|
assert.strictEqual(globalLayout.configDir, FAKE_DIR);
|
|
assert.strictEqual(globalLayout.kinds.length, 2);
|
|
assert.strictEqual(globalLayout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills');
|
|
assert.strictEqual(globalLayout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof globalLayout.kinds[0].stage, 'function');
|
|
assert.strictEqual(globalLayout.kinds[1].kind, 'kimi-agents');
|
|
assert.strictEqual(globalLayout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd');
|
|
assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function');
|
|
|
|
const localLayout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'local');
|
|
assert.strictEqual(localLayout.runtime, 'kimi');
|
|
assert.strictEqual(localLayout.configDir, FAKE_DIR);
|
|
assert.deepStrictEqual(localLayout.kinds, []);
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — hermes', () => {
|
|
// #2875 Part 2: agents kind added — hermes was never in the deleted
|
|
// `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote hermes agents
|
|
// too (brand-swapped via the new named converter convertClaudeAgentToHermesAgent,
|
|
// whose rewrite data was already declared on hostBehaviors.brandingRewrites).
|
|
test('returns correct layout for hermes', () => {
|
|
const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'hermes');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); // #947: restored canonical prefix
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — codebuddy', () => {
|
|
test('returns correct layout for codebuddy (commands + skills + agents — #789, ADR-1235)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'codebuddy');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 3);
|
|
|
|
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
|
assert.ok(commandsKind, 'must have a commands kind');
|
|
assert.strictEqual(commandsKind.destSubpath, 'commands');
|
|
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof commandsKind.stage, 'function');
|
|
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'must have a skills kind');
|
|
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
|
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skillsKind.stage, 'function');
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
|
|
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
|
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agentsKind.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — cline', () => {
|
|
// #2875 Part 2: agents kind added to cline/global — cline was never in the
|
|
// deleted `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote cline
|
|
// agents too, via convertClaudeAgentToClineAgent.
|
|
test('returns correct layout for cline global (skills-capable since v3.48.0 — #782)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'global');
|
|
assert.strictEqual(layout.runtime, 'cline');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
|
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
|
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
|
});
|
|
|
|
test('cline local: no skills kind (skills are global-only, #782); agents kind present (#2875 Part 2 defect fix, cline-local agents-drop regression)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'local');
|
|
assert.strictEqual(layout.runtime, 'cline');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
const kindNames = layout.kinds.map((k) => k.kind).sort();
|
|
assert.deepStrictEqual(kindNames, ['agents'], 'cline local must declare only the agents kind — no skills kind');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — opencode', () => {
|
|
// #2875 Part 2: agents kind added — opencode/kilo's agents were previously
|
|
// written by a code path entirely separate from this layout
|
|
// (installOpencodeFamilyArtifacts's bespoke writers never called
|
|
// resolveRuntimeArtifactLayout for agents); installAgentsKindStandalone now
|
|
// covers them through the SAME descriptor this layout resolves.
|
|
test('returns commands + skills + agents layout for opencode (#784)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'opencode');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 3);
|
|
|
|
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
|
assert.ok(commands, 'should have a commands kind');
|
|
// #2329: OpenCode discovers commands from the PLURAL `commands/` dir — the
|
|
// singular `command/` made all /gsd-* commands invisible to OpenCode.
|
|
assert.strictEqual(commands.destSubpath, 'commands');
|
|
assert.strictEqual(commands.prefix, 'gsd-');
|
|
assert.strictEqual(typeof commands.stage, 'function');
|
|
|
|
const skills = layout.kinds.find((k) => k.kind === 'skills');
|
|
assert.ok(skills, 'should have a skills kind');
|
|
assert.strictEqual(skills.destSubpath, 'skills');
|
|
assert.strictEqual(skills.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skills.stage, 'function');
|
|
|
|
const agents = layout.kinds.find((k) => k.kind === 'agents');
|
|
assert.ok(agents, 'should have an agents kind');
|
|
assert.strictEqual(agents.destSubpath, 'agents');
|
|
assert.strictEqual(agents.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agents.stage, 'function');
|
|
});
|
|
});
|
|
|
|
describe('resolveRuntimeArtifactLayout — kilo', () => {
|
|
// #2875 Part 2: agents kind added — same reason as opencode above (kilo
|
|
// shares the combined-family install shape).
|
|
test('returns commands + skills + agents layout for kilo (#784)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR);
|
|
assert.strictEqual(layout.runtime, 'kilo');
|
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
|
assert.strictEqual(layout.kinds.length, 3);
|
|
|
|
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
|
assert.ok(commands, 'should have a commands kind');
|
|
assert.strictEqual(commands.destSubpath, 'command');
|
|
assert.strictEqual(commands.prefix, 'gsd-');
|
|
assert.strictEqual(typeof commands.stage, 'function');
|
|
|
|
const skills = layout.kinds.find((k) => k.kind === 'skills');
|
|
assert.ok(skills, 'should have a skills kind');
|
|
assert.strictEqual(skills.destSubpath, 'skills');
|
|
assert.strictEqual(skills.prefix, 'gsd-');
|
|
assert.strictEqual(typeof skills.stage, 'function');
|
|
|
|
const agents = layout.kinds.find((k) => k.kind === 'agents');
|
|
assert.ok(agents, 'should have an agents kind');
|
|
assert.strictEqual(agents.destSubpath, 'agents');
|
|
assert.strictEqual(agents.prefix, 'gsd-');
|
|
assert.strictEqual(typeof agents.stage, 'function');
|
|
});
|
|
});
|
|
|
|
// ─── resolveRuntimeArtifactLayout — edge-cases ──────────────────────────────
|
|
|
|
describe('resolveRuntimeArtifactLayout edge-cases', () => {
|
|
test('hermes has destSubpath skills/gsd and gsd- prefix (#947: restored from bare-stem)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('hermes', '/tmp/x');
|
|
assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd');
|
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); // #947: bare-stem prefix='' reversed
|
|
});
|
|
|
|
test('claude local has both commands and agents kinds', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'local');
|
|
const kindNames = layout.kinds.map(k => k.kind);
|
|
assert.ok(kindNames.includes('commands'), 'should have commands kind');
|
|
assert.ok(kindNames.includes('agents'), 'should have agents kind');
|
|
});
|
|
|
|
test('cursor has a skills kind and no commands kind (#2644)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('cursor', '/tmp/x');
|
|
const kindNames = layout.kinds.map(k => k.kind);
|
|
assert.ok(kindNames.includes('skills'), 'cursor must have skills kind');
|
|
assert.ok(!kindNames.includes('commands'), 'cursor commands kind would duplicate skill menu entries');
|
|
});
|
|
|
|
// #2875 Part 2: claude/global now also declares an `agents` kind (see the
|
|
// 'resolveRuntimeArtifactLayout — claude global' describe block above for
|
|
// the full "was this new on-disk behavior?" determination — it is not).
|
|
test('claude global has skills and agents kinds', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global');
|
|
assert.strictEqual(layout.kinds.length, 2);
|
|
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
|
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
|
});
|
|
|
|
test('unknown runtime grok throws TypeError containing runtime name', () => {
|
|
assert.throws(
|
|
() => resolveRuntimeArtifactLayout('grok', '/tmp/x'),
|
|
(err) => {
|
|
assert.ok(err instanceof TypeError);
|
|
assert.ok(err.message.includes('grok'), 'error message must contain the runtime name');
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
test('unknown runtime xyzunknown throws TypeError', () => {
|
|
assert.throws(
|
|
() => resolveRuntimeArtifactLayout('xyzunknown', '/tmp/x'),
|
|
TypeError
|
|
);
|
|
});
|
|
|
|
test('empty configDir throws TypeError', () => {
|
|
assert.throws(
|
|
() => resolveRuntimeArtifactLayout('claude', ''),
|
|
TypeError
|
|
);
|
|
});
|
|
|
|
test('non-string configDir throws TypeError', () => {
|
|
assert.throws(
|
|
() => resolveRuntimeArtifactLayout('claude', null),
|
|
TypeError
|
|
);
|
|
});
|
|
|
|
test('bad scope throws TypeError', () => {
|
|
assert.throws(
|
|
() => resolveRuntimeArtifactLayout('claude', '/x', 'invalid'),
|
|
TypeError
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── kind.stage() invocations ────────────────────────────────────────────────
|
|
|
|
const CORE_SKILLS = new Set(['help', 'phase', 'new-project']);
|
|
const CORE_AGENTS = new Set(['gsd-planner']);
|
|
const PROFILE_CORE = { skills: CORE_SKILLS, agents: CORE_AGENTS };
|
|
const PROFILE_FULL = { skills: '*', agents: new Set() };
|
|
const FAKE_STAGE_DIR = '/tmp/fake-config-dir-stage';
|
|
|
|
describe('stage — agents kind (claude local)', () => {
|
|
test('stage returns a valid directory for the agents kind', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'local');
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
|
assert.ok(agentsKind, 'should have an agents kind');
|
|
|
|
const stagedDir = agentsKind.stage(PROFILE_CORE);
|
|
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
|
assert.ok(fs.statSync(stagedDir).isDirectory(), 'stagedDir must be a directory');
|
|
});
|
|
});
|
|
|
|
describe('stage — skills kind (claude global)', () => {
|
|
test('stage returns a directory containing gsd-<stem>/SKILL.md entries', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'global');
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'should have a skills kind');
|
|
|
|
const stagedDir = skillsKind.stage(PROFILE_CORE);
|
|
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
|
const entries = fs.readdirSync(stagedDir);
|
|
for (const entry of entries) {
|
|
assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`);
|
|
const skillMd = path.join(stagedDir, entry, 'SKILL.md');
|
|
assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`);
|
|
}
|
|
assert.ok(entries.length >= 1, 'at least one skill dir should be staged');
|
|
});
|
|
|
|
test('stage with skills="*" produces flat layout for claude (#924: reverted from nested)', () => {
|
|
const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'global');
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'should have a skills kind');
|
|
|
|
const stagedDir = skillsKind.stage(PROFILE_FULL);
|
|
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
|
|
|
// #924: Claude is reverted to FLAT. Full profile produces >= 60 top-level gsd-* dirs.
|
|
// (Previously nested: exactly 6 gsd-ns-* router dirs. That broke Skill-tool discovery.)
|
|
const topEntries = fs.readdirSync(stagedDir);
|
|
assert.ok(
|
|
topEntries.length >= 60,
|
|
`full profile should have >= 60 top-level skill dirs (flat layout, #924), got ${topEntries.length}`,
|
|
);
|
|
for (const entry of topEntries) {
|
|
assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`);
|
|
// Each skill dir has its own SKILL.md at the top level.
|
|
const skillMd = path.join(stagedDir, entry, 'SKILL.md');
|
|
assert.ok(fs.existsSync(skillMd), `SKILL.md must exist at top level in ${entry}`);
|
|
// No nested skills/ subdirectory: flat layout means no nesting.
|
|
const skillsSubdir = path.join(stagedDir, entry, 'skills');
|
|
assert.ok(!fs.existsSync(skillsSubdir), `skills/ subdir must NOT exist in ${entry} (flat layout, #924)`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('stage — skills kind (kimi global)', () => {
|
|
test('stage returns Kimi SKILL.md dirs and agent YAML/prompt artifacts', () => {
|
|
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_STAGE_DIR, 'global');
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'should have a skills kind');
|
|
|
|
const stagedDir = skillsKind.stage(PROFILE_CORE);
|
|
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
|
const skillMd = path.join(stagedDir, 'gsd-new-project', 'SKILL.md');
|
|
assert.ok(fs.existsSync(skillMd), 'gsd-new-project/SKILL.md must exist');
|
|
const content = fs.readFileSync(skillMd, 'utf8');
|
|
assert.match(content, /^name: gsd-new-project$/m);
|
|
assert.match(content, /\/skill:gsd-new-project/);
|
|
assert.doesNotMatch(content, /kimi_cli\.tools|system_prompt_path|^version: 1$/m);
|
|
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'kimi-agents');
|
|
assert.ok(agentsKind, 'should have a kimi-agents kind');
|
|
|
|
const stagedAgentsDir = agentsKind.stage(PROFILE_FULL);
|
|
const rootYamlPath = path.join(stagedAgentsDir, 'gsd.yaml');
|
|
const rootPromptPath = path.join(stagedAgentsDir, 'gsd.md');
|
|
const executorYamlPath = path.join(stagedAgentsDir, 'subagents', 'gsd-executor.yaml');
|
|
const executorPromptPath = path.join(stagedAgentsDir, 'subagents', 'gsd-executor.md');
|
|
assert.ok(fs.existsSync(rootYamlPath), 'agents/gsd.yaml must be staged');
|
|
assert.ok(fs.existsSync(rootPromptPath), 'agents/gsd.md must be staged');
|
|
assert.ok(fs.existsSync(executorYamlPath), 'agents/subagents/gsd-executor.yaml must be staged');
|
|
assert.ok(fs.existsSync(executorPromptPath), 'agents/subagents/gsd-executor.md must be staged');
|
|
|
|
const rootYaml = fs.readFileSync(rootYamlPath, 'utf8');
|
|
assert.match(rootYaml, /^version: 1$/m);
|
|
assert.match(rootYaml, /^agent:$/m);
|
|
assert.match(rootYaml, /extend: default/);
|
|
assert.match(rootYaml, /system_prompt_path: \.\/gsd\.md/);
|
|
assert.match(rootYaml, /tools:/);
|
|
assert.match(rootYaml, /subagents:/);
|
|
assert.match(rootYaml, /kimi_cli\.tools\./);
|
|
assert.doesNotMatch(rootYaml, /mcp__/);
|
|
|
|
const executorYaml = fs.readFileSync(executorYamlPath, 'utf8');
|
|
assert.match(executorYaml, /system_prompt_path: \.\/gsd-executor\.md/);
|
|
assert.match(executorYaml, /kimi_cli\.tools\./);
|
|
assert.doesNotMatch(executorYaml, /mcp__/);
|
|
});
|
|
|
|
test('tracks Kimi agent staging dir before writing artifacts', () => {
|
|
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_STAGE_DIR, 'global');
|
|
const agentsKind = layout.kinds.find(k => k.kind === 'kimi-agents');
|
|
assert.ok(agentsKind, 'should have a kimi-agents kind');
|
|
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
const before = new Set(installProfiles.STAGED_DIRS);
|
|
let added = [];
|
|
|
|
try {
|
|
fs.writeFileSync = function writeFileSyncWithInjectedFailure(file, ...args) {
|
|
const filePath = String(file);
|
|
if (filePath.includes('gsd-kimi-agents-') && path.basename(filePath) === 'gsd.yaml') {
|
|
throw new Error('forced Kimi stage write failure');
|
|
}
|
|
return originalWriteFileSync.call(this, file, ...args);
|
|
};
|
|
|
|
assert.throws(
|
|
() => agentsKind.stage(PROFILE_FULL),
|
|
/forced Kimi stage write failure/
|
|
);
|
|
|
|
added = [...installProfiles.STAGED_DIRS]
|
|
.filter(dir => !before.has(dir) && path.basename(dir).startsWith('gsd-kimi-agents-'));
|
|
assert.strictEqual(added.length, 1, 'partially written Kimi stage dir must be tracked for cleanup');
|
|
assert.ok(fs.existsSync(added[0]), 'tracked partial Kimi stage dir should exist');
|
|
} finally {
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
for (const dir of added) {
|
|
cleanup(dir);
|
|
installProfiles.STAGED_DIRS.delete(dir);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('stage — opencode commands kind', () => {
|
|
test('opencode stage returns directory with .md files for selected skills', () => {
|
|
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_STAGE_DIR);
|
|
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
|
assert.ok(commandsKind, 'should have a commands kind');
|
|
|
|
const stagedDir = commandsKind.stage(PROFILE_CORE);
|
|
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
|
const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
|
|
for (const entry of entries) {
|
|
const stem = entry.slice(0, -3);
|
|
assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('stage — opencode/kilo skills kind (#784)', () => {
|
|
for (const runtime of ['opencode', 'kilo']) {
|
|
test(`${runtime} skills stage writes gsd-<stem>/SKILL.md with name + description`, () => {
|
|
const layout = resolveRuntimeArtifactLayout(runtime, FAKE_STAGE_DIR);
|
|
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'should have a skills kind');
|
|
|
|
const stagedDir = skillsKind.stage(PROFILE_CORE);
|
|
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
|
const entries = fs.readdirSync(stagedDir);
|
|
assert.ok(entries.length >= 1, 'at least one skill dir should be staged');
|
|
for (const entry of entries) {
|
|
assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`);
|
|
const skillMd = path.join(stagedDir, entry, 'SKILL.md');
|
|
assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`);
|
|
const content = fs.readFileSync(skillMd, 'utf8');
|
|
// OpenCode skill spec: name must match the dir, description required.
|
|
assert.ok(content.startsWith('---\n'), 'SKILL.md must open with frontmatter');
|
|
assert.match(content, new RegExp(`^name: ${entry}$`, 'm'), `name must equal dir ${entry}`);
|
|
assert.match(content, /^description: /m, 'description frontmatter required');
|
|
// No colon-namespace command leaks in the converted body.
|
|
assert.ok(!/\/gsd:/.test(content), 'body must not contain /gsd: colon refs');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('stage — cursor retired commands kind (#2644)', () => {
|
|
test('cursor layout exposes no commands staging surface', () => {
|
|
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR);
|
|
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
|
assert.equal(commandsKind, undefined);
|
|
});
|
|
});
|
|
|
|
// ─── #1477: .gsd-source marker provisioning ───────────────────────────────────
|
|
//
|
|
// Regression for #1477: the Claude Code global skills layout ships
|
|
// gsd-core/{bin,contexts,references,templates,workflows} but no commands/gsd
|
|
// source tree, and _runLegacyUninstallCleanup removes any commands/gsd/ for that
|
|
// scope. At runtime findInstallSourceRoot's walk-up from gsd-core/bin/lib had
|
|
// nothing to find and /gsd-surface threw for every subcommand (list/status
|
|
// included); the marker reader added in #1476 never fired because nothing wrote
|
|
// the marker.
|
|
//
|
|
// Fix: bin/install.js writes <configDir>/.gsd-source pointing at a resolvable
|
|
// commands/gsd, and findInstallSourceRoot prefers that marker over its walk-up.
|
|
//
|
|
// (The original #1477 also covered a deployed-install MODULE_NOT_FOUND from the
|
|
// surface path's relative require('../../../bin/install.js'); ADR-1508 / #1511
|
|
// removed that getInstallExports relay entirely — surface.cjs now calls the
|
|
// shipped runtime-artifact-conversion sibling directly — so that half no longer
|
|
// applies and is covered by the #1511 relocation suite.)
|
|
|
|
describe('#1477 .gsd-source marker provisioning', () => {
|
|
let tmpRoot;
|
|
let savedHome;
|
|
let savedUserProfile;
|
|
let savedExplicitConfigDir;
|
|
let savedTestMode;
|
|
let restoreConfigLocationEnv;
|
|
|
|
function silenceConsole(fn) {
|
|
const orig = { log: console.log, warn: console.warn, error: console.error };
|
|
console.log = () => {};
|
|
console.warn = () => {};
|
|
console.error = () => {};
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
console.log = orig.log;
|
|
console.warn = orig.warn;
|
|
console.error = orig.error;
|
|
}
|
|
}
|
|
|
|
// Guard against process.exit killing the runner mid-install.
|
|
function runInstall(isGlobal, runtime) {
|
|
const origExit = process.exit;
|
|
let exitCalled = false;
|
|
process.exit = (code) => {
|
|
exitCalled = true;
|
|
throw new Error(`process.exit(${code}) during install — should not happen`);
|
|
};
|
|
try {
|
|
return silenceConsole(() => install(isGlobal, runtime));
|
|
} catch (e) {
|
|
if (exitCalled) assert.fail(`install() called process.exit — unexpected: ${e.message}`);
|
|
throw e;
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = createTempDir('gsd-1477-');
|
|
savedHome = process.env.HOME;
|
|
// os.homedir() reads USERPROFILE on win32, HOME elsewhere; redirect both so
|
|
// install() targets the fixture regardless of platform.
|
|
savedUserProfile = process.env.USERPROFILE;
|
|
process.env.HOME = tmpRoot;
|
|
process.env.USERPROFILE = tmpRoot;
|
|
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
|
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
|
savedTestMode = process.env.GSD_TEST_MODE;
|
|
process.env.GSD_TEST_MODE = '1';
|
|
// #2665: this block calls install(true, 'claude') IN-PROCESS. Redirecting
|
|
// HOME/USERPROFILE is not enough, because getGlobalConfigDir is env-FIRST:
|
|
// an ambient CLAUDE_CONFIG_DIR wins over the fixture and a full global
|
|
// install lands in the developer's live config dir. Found by the post-suite
|
|
// hermeticity guard (scripts/live-config-guard.cjs), not by inspection.
|
|
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = savedHome;
|
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = savedUserProfile;
|
|
if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
|
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
|
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
|
else process.env.GSD_TEST_MODE = savedTestMode;
|
|
restoreConfigLocationEnv();
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
// ── Failure 1: the installer provisions a valid marker ──────────────────────
|
|
test('global claude install writes a .gsd-source marker pointing at a real commands/gsd', () => {
|
|
const claudeDir = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
|
|
runInstall(true /* isGlobal */, 'claude');
|
|
|
|
const markerPath = path.join(claudeDir, '.gsd-source');
|
|
assert.ok(fs.existsSync(markerPath), `.gsd-source marker must be written at ${markerPath}`);
|
|
|
|
const markerSrc = fs.readFileSync(markerPath, 'utf8').trim();
|
|
assert.ok(path.isAbsolute(markerSrc), `marker must contain an absolute path, got: ${markerSrc}`);
|
|
assert.ok(fs.existsSync(markerSrc), `marker target must exist on disk: ${markerSrc}`);
|
|
assert.equal(path.basename(markerSrc), 'gsd', 'marker must point at a commands/gsd directory');
|
|
assert.equal(path.basename(path.dirname(markerSrc)), 'commands');
|
|
});
|
|
|
|
// ── Guard: marker is scoped to claude-global ONLY (#1477 PR-scope) ───────────
|
|
// Locks the `runtime === 'claude' && isGlobal` write guard. Every other layout
|
|
// (non-claude runtimes, and claude *local*) ships a commands/gsd source tree, so
|
|
// findInstallSourceRoot's walk-up already resolves and the marker must not appear.
|
|
function findGsdSourceMarkers(root) {
|
|
const found = [];
|
|
const walk = (dir) => {
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) walk(full);
|
|
else if (e.name === '.gsd-source') found.push(full);
|
|
}
|
|
};
|
|
walk(root);
|
|
return found;
|
|
}
|
|
|
|
test('non-claude global install writes no .gsd-source marker (locks runtime === claude)', () => {
|
|
runInstall(true /* isGlobal */, 'cursor');
|
|
assert.deepEqual(
|
|
findGsdSourceMarkers(tmpRoot), [],
|
|
'a non-claude runtime must not provision the claude-global marker',
|
|
);
|
|
});
|
|
|
|
test('claude local install writes no .gsd-source marker (locks isGlobal)', () => {
|
|
// Local installs target process.cwd()/.claude — chdir into the fixture so the
|
|
// install is contained within tmpRoot rather than polluting the repo.
|
|
const savedCwd = process.cwd();
|
|
process.chdir(tmpRoot);
|
|
try {
|
|
runInstall(false /* isGlobal */, 'claude');
|
|
} finally {
|
|
process.chdir(savedCwd);
|
|
}
|
|
assert.deepEqual(
|
|
findGsdSourceMarkers(tmpRoot), [],
|
|
'a claude local install must not provision the marker — local ships commands/gsd',
|
|
);
|
|
});
|
|
|
|
// ── Writer fault-injection: marker write failure is non-fatal + warns ────────
|
|
// CONTRIBUTING.md filesystem-write QA matrix: prove the marker-writer catch
|
|
// branch (bin/install.js) is reachable. A failed write (e.g. read-only target)
|
|
// must not abort the install, and — because walk-up also fails on this layout —
|
|
// must surface a diagnostic so the broken /gsd-surface is traceable.
|
|
test('marker write failure does not abort install and warns (locks the writer catch)', () => {
|
|
const claudeDir = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
|
|
const markerPath = path.join(claudeDir, '.gsd-source');
|
|
const realWriteFileSync = fs.writeFileSync;
|
|
// Fault-inject ONLY the marker write; every other install write proceeds.
|
|
mock.method(fs, 'writeFileSync', (file, ...rest) => {
|
|
if (path.resolve(String(file)) === path.resolve(markerPath)) {
|
|
throw new Error('EACCES: read-only target (injected)');
|
|
}
|
|
return realWriteFileSync(file, ...rest);
|
|
});
|
|
|
|
// Capture console.warn around the install (runInstall's silenceConsole would
|
|
// otherwise swallow it); still guard process.exit.
|
|
const warnings = [];
|
|
const origExit = process.exit;
|
|
const origWarn = console.warn;
|
|
const origLog = console.log;
|
|
process.exit = (code) => { throw new Error(`process.exit(${code}) during install`); };
|
|
console.warn = (msg) => { warnings.push(String(msg)); };
|
|
console.log = () => {};
|
|
try {
|
|
assert.doesNotThrow(() => install(true /* isGlobal */, 'claude'),
|
|
'a marker write failure must be non-fatal — install proceeds via the catch');
|
|
} finally {
|
|
process.exit = origExit;
|
|
console.warn = origWarn;
|
|
console.log = origLog;
|
|
mock.restoreAll();
|
|
}
|
|
|
|
assert.ok(!fs.existsSync(markerPath), 'the injected fault must leave no marker on disk');
|
|
assert.ok(
|
|
warnings.some((w) => /\.gsd-source marker/.test(w)),
|
|
`the writer catch must warn for diagnosability; got: ${JSON.stringify(warnings)}`,
|
|
);
|
|
});
|
|
|
|
// ── Failure 1 end-to-end: resolution succeeds FROM the deployed tree ─────────
|
|
// The deployed module's __dirname is <claudeDir>/gsd-core/bin/lib, which has no
|
|
// commands/gsd ancestor (global skills layout). Only the marker rescues it.
|
|
test('deployed global layout resolves the source root via the marker', () => {
|
|
const claudeDir = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
runInstall(true /* isGlobal */, 'claude');
|
|
|
|
// Sanity: the global layout genuinely ships no commands/gsd source tree.
|
|
assert.ok(
|
|
!fs.existsSync(path.join(claudeDir, 'commands', 'gsd')),
|
|
'precondition: global claude install must not ship commands/gsd',
|
|
);
|
|
|
|
const deployedLayoutPath = path.join(claudeDir, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs');
|
|
assert.ok(fs.existsSync(deployedLayoutPath), 'deployed runtime-artifact-layout.cjs must exist');
|
|
delete require.cache[deployedLayoutPath];
|
|
const deployed = require(deployedLayoutPath);
|
|
|
|
// Negative proof that the bug condition exists: WITHOUT consulting the marker
|
|
// (no configDir argument), walk-up from the deployed tree has nothing to find.
|
|
assert.throws(
|
|
() => deployed.findInstallSourceRoot(),
|
|
/could not locate commands\/gsd/,
|
|
'deployed walk-up must fail without the marker — this is the regression condition',
|
|
);
|
|
|
|
// With the marker (configDir provided), list/status resolution succeeds.
|
|
let resolved;
|
|
assert.doesNotThrow(() => {
|
|
resolved = deployed.findInstallSourceRoot(claudeDir);
|
|
}, 'findInstallSourceRoot must resolve via the .gsd-source marker');
|
|
assert.equal(path.basename(resolved), 'gsd');
|
|
assert.ok(fs.existsSync(resolved));
|
|
});
|
|
|
|
// ── Adversarial marker-reader cases (no full install needed) ─────────────────
|
|
describe('findInstallSourceRoot marker handling', () => {
|
|
let cfgDir;
|
|
beforeEach(() => { cfgDir = createTempDir('gsd-1477-marker-'); });
|
|
afterEach(() => { cleanup(cfgDir); });
|
|
|
|
test('marker pointing at a valid commands/gsd takes precedence over walk-up', () => {
|
|
const fakeSrc = path.join(cfgDir, 'pkg', 'commands', 'gsd');
|
|
fs.mkdirSync(fakeSrc, { recursive: true });
|
|
fs.writeFileSync(path.join(cfgDir, '.gsd-source'), fakeSrc + '\n', 'utf8');
|
|
|
|
const resolved = findInstallSourceRoot(cfgDir);
|
|
assert.equal(path.resolve(resolved), path.resolve(fakeSrc),
|
|
'marker target must win over the repo walk-up');
|
|
});
|
|
|
|
test('marker pointing at a non-existent path is ignored (falls through to walk-up)', () => {
|
|
const ghost = path.join(cfgDir, 'does', 'not', 'exist', 'commands', 'gsd');
|
|
fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ghost + '\n', 'utf8');
|
|
|
|
const resolved = findInstallSourceRoot(cfgDir);
|
|
assert.notEqual(path.resolve(resolved), path.resolve(ghost));
|
|
assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd'));
|
|
});
|
|
|
|
test('empty / whitespace-only marker is ignored', () => {
|
|
fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ' \n', 'utf8');
|
|
const resolved = findInstallSourceRoot(cfgDir);
|
|
assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd'));
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── #2624: stale .gsd-source marker read before rewrite on upgrade ──────────
|
|
//
|
|
// Regression for #2624: a Claude-global upgrade silently installed skill content
|
|
// from the PREVIOUS version. findInstallSourceRoot prefers <configDir>/.gsd-source,
|
|
// and install() used to REWRITE that marker AFTER staging had already read it — so
|
|
// on an upgrade the marker still pointed at the prior version's source (an npx
|
|
// cache dir that still exists on disk) and every converted skill was generated from
|
|
// the OLD commands/gsd. Fix: write the marker BEFORE staging reads it.
|
|
//
|
|
// These exercise the real install(true, 'claude') with HOME redirected to a tmp dir,
|
|
// pre-seeding a STALE marker that points at a different (still-existing) source —
|
|
// the exact upgrade condition. No live npx / network.
|
|
describe('#2624 .gsd-source marker is rewritten before staging reads it', () => {
|
|
let tmpRoot;
|
|
let savedHome;
|
|
let savedUserProfile;
|
|
let savedExplicitConfigDir;
|
|
let savedTestMode;
|
|
let restoreConfigLocationEnv;
|
|
|
|
// Run install() with process.exit and console output mocked via t.mock (auto-restored),
|
|
// per CONTRIBUTING.md test rules (no manual monkeypatch / try-finally in test bodies).
|
|
// process.exit during install is a hard failure — surface it, don't let it kill the runner.
|
|
function runInstall(t, isGlobal, runtime) {
|
|
t.mock.method(process, 'exit', (code) => {
|
|
throw new Error(`process.exit(${code}) during install — should not happen`);
|
|
});
|
|
t.mock.method(console, 'log', () => {});
|
|
t.mock.method(console, 'warn', () => {});
|
|
t.mock.method(console, 'error', () => {});
|
|
return install(isGlobal, runtime);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = createTempDir('gsd-2624-');
|
|
savedHome = process.env.HOME;
|
|
savedUserProfile = process.env.USERPROFILE;
|
|
process.env.HOME = tmpRoot;
|
|
process.env.USERPROFILE = tmpRoot;
|
|
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
|
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
|
savedTestMode = process.env.GSD_TEST_MODE;
|
|
process.env.GSD_TEST_MODE = '1';
|
|
// #2665: same in-process hazard as the block above. This one calls
|
|
// install(true, 'claude') via runInstall(), and HOME/USERPROFILE alone do
|
|
// not contain it — getGlobalConfigDir is env-FIRST, so an ambient
|
|
// CLAUDE_CONFIG_DIR beats the fixture, the install lands in the developer's
|
|
// live config dir, and these tests then fail looking for a marker under
|
|
// tmpRoot that was never written there.
|
|
restoreConfigLocationEnv = scrubConfigLocationEnv();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = savedHome;
|
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = savedUserProfile;
|
|
if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
|
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
|
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
|
else process.env.GSD_TEST_MODE = savedTestMode;
|
|
restoreConfigLocationEnv();
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
// The current package's commands/gsd — what the marker MUST point at after install.
|
|
const CURRENT_SOURCE = path.join(REPO_ROOT, 'commands', 'gsd');
|
|
|
|
test('claude-global install overwrites a stale .gsd-source marker before staging reads it', (t) => {
|
|
const claudeDir = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
|
|
// Simulate the PREVIOUS install's marker: a different source dir that STILL EXISTS
|
|
// on disk (mirroring a coexisting npx per-version cache dir). findInstallSourceRoot
|
|
// returns this immediately if it is read before the marker is rewritten.
|
|
const staleSource = path.join(tmpRoot, 'old-npx-cache', 'commands', 'gsd');
|
|
fs.mkdirSync(staleSource, { recursive: true });
|
|
fs.writeFileSync(path.join(claudeDir, '.gsd-source'), staleSource + '\n', 'utf8');
|
|
|
|
// Spy on findInstallSourceRoot to capture WHICH source staging actually resolves.
|
|
// The marker ends up correct either way (the late write corrected it on the old code),
|
|
// so the marker content alone cannot prove the ordering — the resolved-source captures
|
|
// can. Before the fix, staging resolves the stale path; after, the current path.
|
|
// install-engine.cjs calls runtimeArtifactLayout.findInstallSourceRoot via the module
|
|
// object (not a captured ref), so mocking the property on the shared module instance
|
|
// intercepts the staging call. Same pattern as tests/phase.test.cjs (capture original,
|
|
// delegate).
|
|
const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
const realFindInstallSourceRoot = runtimeArtifactLayout.findInstallSourceRoot;
|
|
const resolvedSources = [];
|
|
t.mock.method(runtimeArtifactLayout, 'findInstallSourceRoot', function (configDir) {
|
|
const result = realFindInstallSourceRoot.call(this, configDir);
|
|
resolvedSources.push(path.resolve(result));
|
|
return result;
|
|
});
|
|
|
|
runInstall(t, true /* isGlobal */, 'claude');
|
|
|
|
const markerPath = path.join(claudeDir, '.gsd-source');
|
|
assert.ok(fs.existsSync(markerPath), 'marker must exist after install');
|
|
const finalMarker = path.resolve(fs.readFileSync(markerPath, 'utf8').trim());
|
|
assert.equal(finalMarker, path.resolve(CURRENT_SOURCE),
|
|
`final marker must point at the current package source, not ${finalMarker}`);
|
|
|
|
// The decisive assertion: staging must NEVER have resolved the stale source. Before the
|
|
// fix, at least one staging resolution returned the stale path (read before rewrite).
|
|
const resolvedStale = resolvedSources.filter((p) => p === path.resolve(staleSource));
|
|
assert.equal(resolvedStale.length, 0,
|
|
`staging must not resolve the stale source during install, but did ${resolvedStale.length} time(s). ` +
|
|
`Resolved: ${JSON.stringify(resolvedSources)}`);
|
|
});
|
|
|
|
test('fresh claude-global install (no prior marker) still writes the correct marker', (t) => {
|
|
const claudeDir = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
// No pre-existing marker — fresh install (negative space, must stay correct).
|
|
runInstall(t, true /* isGlobal */, 'claude');
|
|
|
|
const markerPath = path.join(claudeDir, '.gsd-source');
|
|
assert.ok(fs.existsSync(markerPath), 'fresh claude-global install must write the marker');
|
|
const resolved = fs.readFileSync(markerPath, 'utf8').trim();
|
|
assert.equal(
|
|
path.resolve(resolved),
|
|
path.resolve(CURRENT_SOURCE),
|
|
'fresh install marker must point at the current package source',
|
|
);
|
|
});
|
|
|
|
test('claude-global install rewrites a marker pointing at a deleted (ghost) source', (t) => {
|
|
const claudeDir = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
// A ghost path that does NOT exist on disk — findInstallSourceRoot ignores it and
|
|
// falls through to walk-up, then install() rewrites the marker to the current source.
|
|
const ghost = path.join(tmpRoot, 'gone', 'commands', 'gsd');
|
|
fs.writeFileSync(path.join(claudeDir, '.gsd-source'), ghost + '\n', 'utf8');
|
|
|
|
runInstall(t, true /* isGlobal */, 'claude');
|
|
|
|
const markerPath = path.join(claudeDir, '.gsd-source');
|
|
assert.ok(fs.existsSync(markerPath), 'marker must exist after install');
|
|
const resolved = fs.readFileSync(markerPath, 'utf8').trim();
|
|
assert.equal(
|
|
path.resolve(resolved),
|
|
path.resolve(CURRENT_SOURCE),
|
|
'ghost marker must be rewritten to the current package source',
|
|
);
|
|
});
|
|
});
|