Files
msd-core/tests/install.test.cjs
Tom Boucher 6f2520786d feat(#498): single Package Identity seam for /gsd:update + fix runtime undefined-name bug (#499)
* feat(#498): generated package-identity seam derived from package.json

Introduce a single source for GSD's published-package coordinates:
scripts/generate-package-identity.cjs (pure deriveIdentity + formatManualInstall
+ render) emits the generated get-shit-done/bin/lib/package-identity.cjs with
values baked from package.json at build time. Baking is required because the
installed tree carries only a synthetic {"type":"commonjs"} package.json, so a
runtime require('package.json').name resolves to undefined (#378). Reconciles

Wired into npm run build; a parity test fails CI if the committed file drifts
from package.json.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): repoint update worker + check-latest-version at the seam

- check-latest-version.cjs sources PACKAGE_NAME from the package-identity seam
  instead of a re-typed literal (single source; #2992's constant guarantee is
  preserved since the seam bakes from package.json).
- gsd-check-update-worker.js no longer does require('../package.json').name
  (resolved to undefined in the installed tree → background update check
  silently broken, #378). It now delegates the latest-version lookup to
  checkLatestVersion(), collapsing the duplicated npm-view call onto the single
  deterministic adapter and inheriting its typed {ok,version,reason} surface.
- Move the PR #3102 Windows shell-gate contract test onto execNpm (where the
  spawn now lives) and assert the worker no longer spawns npm directly.
- Rewrite the #378 contract: worker must NOT use require(package.json).name and
  must delegate; check-latest-version PACKAGE_NAME is single-sourced from the seam.

Fixes #378-class runtime breakage. Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#498): changeset for package-identity seam + update-check fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#498): drift-guard lint — value-check GSD coordinate literals against the seam

scripts/lint-package-identity-drift.cjs scans the runtime/code surface
(bin/, hooks/, scripts/, get-shit-done/) and asserts every GSD package name
and GitHub repo slug literal equals the Package Identity seam's current value.
Passes today; fails the moment a repoint isn't propagated (rename package.json,
regenerate the seam, and stale literals are reported until updated). This is
the second adapter that makes the seam real and a repoint mechanically safe.

Enforced via tests/issue-498-identity-drift-lint.test.cjs (scanRepo === [])
under npm test; also exposed as `npm run check:identity-drift`.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#498): update-context projection — port update.md resolution to a tested seam

Add get-shit-done/bin/lib/update-context.cjs: a pure, injected-fs port of
update.md's ~280-line get_installed_version bash. resolveUpdateContext()
reproduces the full precedence cascade (preferred fast-path -> local probe ->
global probe via env overrides then $HOME -> LOCAL-if-distinct -> scope
cascade -> UNKNOWN) and returns the 4-field contract { installedVersion,
scope, runtime, gsdDir }. The fs is injected so every branch is finally
testable without a live multi-runtime install.

Expose it as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`.
Purely additive — update.md is unchanged in this commit; the workflow swap
follows separately.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#498): swap update.md resolution to the update-context projection

Replace ~280 lines of inline runtime/scope/config-dir bash in update.md's
get_installed_version step with a call to `gsd-tools update-context --json`
(60 lines: derive PREFERRED_* from execution_context, resolve gsd-tools.cjs,
parse the 4-field JSON). Behavior is unchanged — the projection reproduces the
same cascade — but the logic is now tested in update-context.cjs instead of
untestable bash-in-markdown.

Relocate the #3608 antigravity-first-class contract onto the projection
(RUNTIME_DIRS order, inferPreferredRuntime, envRuntimeDirs) plus a behavioral
test; keep the execution_context path-classification assertion on update.md.
Re-point install.test's custom-config-dir assertion (kilo.jsonc/KILO_CONFIG)
to update-context.cjs where that detection now lives.

Full root suite: 2022 pass / 0 fail.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#498): record Update Context Module in CONTEXT.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): CI — avoid bare gsd-tools in update.md; register new CLI modules

- update.md update-context invocation: resolve the PATH gsd-tools shim into a
  variable and call "$GSD_TOOLS" (never a bare `gsd-tools` command) — satisfies
  the #2851 workflow-bare-gsd-tools guard.
- Register package-identity.cjs and update-context.cjs in docs/INVENTORY.md
  (CLI Modules 76 -> 78 + rows) and regenerate docs/INVENTORY-MANIFEST.json,
  fixing inventory-counts and inventory-manifest-sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#498): make update-context + parity tests OS-agnostic (Windows CI)

Two Windows-only test failures, both test-portability (production code is fine —
the real-fs CLI integration test passed on Windows):

- update-context resolver tests + bug-3608 behavioral test used POSIX path-string
  keys in their fake fs, but the resolver builds lookups via path.join/resolve
  (backslash + drive letter on Windows) → keys never matched → everything
  resolved to UNKNOWN/claude. Normalize fake-fs keys and gsdDir comparisons
  through path.resolve so they match on both platforms.
- package-identity parity test compared render() (LF) to the committed file,
  which Windows git checks out as CRLF (no .gitattributes eol rule). Normalize
  line endings before comparing, matching the repo convention
  (autonomous-decomposition, bug-3707).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): update.md backup must use GSD_DIR (adversarial-review finding)

The get_installed_version rewrite emits GSD_DIR but dropped the probe-loop
variables LOCAL_DIR/GLOBAL_DIR. The backup_custom_files step still read those,
so RUNTIME_DIR went empty for every LOCAL/GLOBAL install and detect-custom-files
was skipped — and since the update then runs a clean install that wipes managed
dirs (commands/gsd, get-shit-done), user-added files could be deleted without
the intended backup.

Set RUNTIME_DIR="$GSD_DIR" directly (the resolved config dir; empty for
UNKNOWN scope, which still skips the backup). Add a structural regression
(tests/issue-498-update-backup-runtime-dir.test.cjs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#503): re-point Antigravity .agent detection at the #498 projection

#499 moves the runtime/scope detection cascade out of update.md inline bash
into get-shit-done/bin/lib/update-context.cjs. The #503 regression test asserted
on the inline RUNTIME_DIRS array, which no longer exists, so it would fail
against the projected update.md even though the .agent guarantee is preserved.

Rewrite it to verify the surviving surfaces:
 - behavioral: resolveUpdateContext resolves a LOCAL ./.agent install to the
   antigravity runtime (the original root cause, now covered by adding
   ['antigravity', '.agent'] to the projection RUNTIME_DIRS table)
 - update.md prose classifier still maps /.agent/ -> antigravity
 - the post-update cache-clear for-dir loop still includes .agent

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): finish de-hardcoding consumers + close adversarial-review parity gaps

Restore the consumer de-hardcoding that is the point of the seam, and close the
parity gaps an adversarial review (codex) found in the update-context projection.

De-hardcode the repo slug + install command in the changeset tooling — #516
only single-sourced the package NAME, leaving 'open-gsd/get-shit-done-redux'
hardcoded in scripts/changeset/cli.cjs and github-release-notes.cjs. Route both
through the seam's repoSlug/packageName so a rename is a regenerate, not a hand
edit. The drift-lint real scan now reports zero divergent coordinate literals.

Projection parity vs the old inline bash, as ONE consistent rule
(trustedVersionAt) applied on every path:
 - expand a leading ~/ in preferredConfigDir before the fast path (the bash ran
   expand_home first; a custom --config-dir ~/foo otherwise fell to UNKNOWN)
 - trust a version only when BOTH VERSION and the update.md marker exist — fast
   path AND LOCAL/GLOBAL cascade; a partial dir falls to 0.0.0 keeping scope
 - apply the same same-path dedup to the 0.0.0 fallback so a partial install
   probed from cwd===home is not misdetected as LOCAL

Adds regression tests for tilde expansion, VERSION-only (cascade + fast path),
and the cwd===home partial-install dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:53:44 -04:00

684 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Installer Module — Sections 1–5.
*
* Covers: getDirName/getGlobalDir/getConfigDirFromHome, per-runtime
* install/uninstall spot-checks (hermes/qwen/trae), uninstall skills
* cleanup, Claude-reference leak tests, and Kilo-specific helpers.
*
* Consolidates (original sources from #3758):
* hermes-install.test.cjs
* kilo-install.test.cjs
* qwen-install.test.cjs
* trae-install.test.cjs
* antigravity-install.test.cjs
*
* Closes #3758
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createTempDir, createTempProject, cleanup, parseFrontmatter } = require('./helpers.cjs');
const pkg = require('../package.json');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
install,
uninstall,
writeManifest,
allRuntimes,
runtimeMap,
buildRuntimePromptText,
resolveKiloConfigPath,
configureKiloPermissions,
} = require('../bin/install.js');
const {
RUNTIME_META,
SKILL_RUNTIMES,
stripAnsi,
walk,
} = require('./helpers/install-shared.cjs');
// ─── Section 1: getDirName / getGlobalDir / getConfigDirFromHome ──────────────
describe('getDirName — all runtimes', () => {
for (const runtime of allRuntimes) {
test(`getDirName('${runtime}') returns expected local directory name`, () => {
const expected = RUNTIME_META[runtime].localDir;
assert.strictEqual(getDirName(runtime), expected,
`getDirName('${runtime}') should return '${expected}'`);
});
}
});
describe('getGlobalDir — all runtimes default paths', () => {
// Test the default (no env var, no explicit dir) for each runtime
const ENV_KEYS = [
'HERMES_HOME', 'QWEN_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME',
];
let savedEnv = {};
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] !== undefined) process.env[key] = savedEnv[key];
else delete process.env[key];
}
});
for (const runtime of allRuntimes) {
test(`getGlobalDir('${runtime}') returns expected home-relative path`, () => {
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
assert.strictEqual(getGlobalDir(runtime), expected);
});
}
});
describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
const saved = {};
beforeEach(() => {
saved.HOME = process.env.HOME;
saved.USERPROFILE = process.env.USERPROFILE;
saved.ANTIGRAVITY_CONFIG_DIR = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
});
afterEach(() => {
if (saved.HOME !== undefined) process.env.HOME = saved.HOME;
else delete process.env.HOME;
if (saved.USERPROFILE !== undefined) process.env.USERPROFILE = saved.USERPROFILE;
else delete process.env.USERPROFILE;
if (saved.ANTIGRAVITY_CONFIG_DIR !== undefined) process.env.ANTIGRAVITY_CONFIG_DIR = saved.ANTIGRAVITY_CONFIG_DIR;
else delete process.env.ANTIGRAVITY_CONFIG_DIR;
});
test('uses ~/.gemini/antigravity-ide when legacy dir is absent and ide dir exists', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-ide-'));
try {
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-ide'), { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalDir('antigravity'),
path.join(home, '.gemini', 'antigravity-ide'),
);
assert.strictEqual(
getConfigDirFromHome('antigravity', true),
"'.gemini', 'antigravity-ide'",
);
} finally {
cleanup(home);
}
});
test('uses ~/.gemini/antigravity-cli when only cli dir exists', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-cli-'));
try {
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli'), { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalDir('antigravity'),
path.join(home, '.gemini', 'antigravity-cli'),
);
assert.strictEqual(
getConfigDirFromHome('antigravity', true),
"'.gemini', 'antigravity-cli'",
);
} finally {
cleanup(home);
}
});
});
describe('getGlobalDir — explicit configDir overrides env for all runtimes', () => {
test('explicit dir overrides any env var for hermes', () => {
const savedHome = process.env.HERMES_HOME;
process.env.HERMES_HOME = '~/from-env';
try {
assert.strictEqual(getGlobalDir('hermes', '/explicit/hermes'), '/explicit/hermes');
} finally {
if (savedHome !== undefined) process.env.HERMES_HOME = savedHome;
else delete process.env.HERMES_HOME;
}
});
test('explicit dir overrides KILO_CONFIG_DIR', () => {
const saved = process.env.KILO_CONFIG_DIR;
process.env.KILO_CONFIG_DIR = '~/from-env';
try {
assert.strictEqual(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
} finally {
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
else delete process.env.KILO_CONFIG_DIR;
}
});
});
describe('getGlobalDir — HERMES_HOME env var', () => {
let saved;
beforeEach(() => { saved = process.env.HERMES_HOME; });
afterEach(() => {
if (saved !== undefined) process.env.HERMES_HOME = saved;
else delete process.env.HERMES_HOME;
});
test('respects HERMES_HOME env var (tilde-expanded)', () => {
process.env.HERMES_HOME = '~/custom-hermes';
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
});
});
describe('getGlobalDir — Kilo env var priority', () => {
let savedEnv;
beforeEach(() => {
savedEnv = {
KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR,
KILO_CONFIG: process.env.KILO_CONFIG,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
};
delete process.env.KILO_CONFIG_DIR;
delete process.env.KILO_CONFIG;
delete process.env.XDG_CONFIG_HOME;
});
afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v !== undefined) process.env[k] = v;
else delete process.env[k];
}
});
test('respects KILO_CONFIG_DIR', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
});
test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR unset', () => {
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
});
test('KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
});
describe('getConfigDirFromHome — spot-checks', () => {
test('claude returns .claude for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('claude', false), "'.claude'");
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
});
test('hermes returns .hermes for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('hermes', false), "'.hermes'");
assert.strictEqual(getConfigDirFromHome('hermes', true), "'.hermes'");
});
test('qwen returns .qwen for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('qwen', false), "'.qwen'");
assert.strictEqual(getConfigDirFromHome('qwen', true), "'.qwen'");
});
test('trae returns .trae for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('trae', false), "'.trae'");
assert.strictEqual(getConfigDirFromHome('trae', true), "'.trae'");
});
test('antigravity returns .agent (local) and legacy fallback global path when no 2.x dirs exist', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-empty-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedAntigravityConfig = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
process.env.HOME = home;
process.env.USERPROFILE = home;
try {
assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agent'");
assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'");
} finally {
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 (savedAntigravityConfig === undefined) delete process.env.ANTIGRAVITY_CONFIG_DIR;
else process.env.ANTIGRAVITY_CONFIG_DIR = savedAntigravityConfig;
cleanup(home);
}
});
test('kilo returns .kilo (local) and .config, kilo (global)', () => {
assert.strictEqual(getConfigDirFromHome('kilo', false), "'.kilo'");
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
});
});
// ─── Section 2: Local install / uninstall for subset of runtimes ─────────────
// Full E2E for runtimes that have distinct install paths (hermes nested layout,
// qwen flat layout, trae flat layout). Others are covered by layout-loop tests.
describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-hermes-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.hermes and removes it cleanly', () => {
const result = install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
assert.strictEqual(result.runtime, 'hermes');
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
'DESCRIPTION.md at category root');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'hermes');
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd/help/')),
JSON.stringify(manifest.files));
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help')));
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')));
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const categoryDir = path.join(targetDir, 'skills', 'gsd');
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name !== 'DESCRIPTION.md')
.map(e => e.name);
assert.ok(skillDirs.length > 0, 'at least one skill installed');
for (const dir of skillDirs) {
const content = fs.readFileSync(path.join(categoryDir, dir, 'SKILL.md'), 'utf8');
const fm = parseFrontmatter(content);
assert.strictEqual(fm.name, dir, `${dir}/SKILL.md name matches dir`);
assert.ok(typeof fm.description === 'string' && fm.description.length > 0,
`${dir}/SKILL.md has description`);
assert.strictEqual(fm.version, pkg.version,
`${dir}/SKILL.md declares version ${pkg.version}`);
}
const desc = fs.readFileSync(path.join(categoryDir, 'DESCRIPTION.md'), 'utf8');
const descFm = parseFrontmatter(desc);
assert.strictEqual(descFm.name, 'gsd');
assert.ok(typeof descFm.description === 'string' && descFm.description.length > 0);
assert.strictEqual(descFm.version, pkg.version);
uninstall(false, 'hermes');
});
test('replaces CLAUDE.md references with HERMES.md', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const skillsDir = path.join(targetDir, 'skills');
let referencedHermesMd = false;
const checkWalk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { checkWalk(full); continue; }
if (!entry.name.endsWith('.md')) continue;
const content = fs.readFileSync(full, 'utf8');
assert.ok(!/\bCLAUDE\.md\b/.test(content),
`${path.relative(targetDir, full)} still references CLAUDE.md`);
if (/\bHERMES\.md\b/.test(content)) referencedHermesMd = true;
}
};
checkWalk(skillsDir);
assert.ok(referencedHermesMd, 'at least one skill references HERMES.md');
uninstall(false, 'hermes');
});
});
describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-qwen-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.qwen and removes it cleanly', () => {
const result = install(false, 'qwen');
const targetDir = path.join(tmpDir, '.qwen');
assert.strictEqual(result.runtime, 'qwen');
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'qwen');
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/')));
uninstall(false, 'qwen');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
});
describe('install/uninstall — trae (flat skills/gsd-* layout)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-trae-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.trae and removes it cleanly (typed IR result)', () => {
const result = install(false, 'trae');
const targetDir = path.join(tmpDir, '.trae');
assert.deepStrictEqual(result, {
settingsPath: null,
settings: null,
statuslineCommand: null,
updateBannerCommand: null,
runtime: 'trae',
configDir: fs.realpathSync(targetDir),
});
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'trae');
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/')));
uninstall(false, 'trae');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
});
// ─── Section 3: Uninstall skills cleanup — parameterised ─────────────────────
describe('uninstall skills cleanup — hermes', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-hermes-uninstall-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('removes skills/gsd/ category dir', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const categoryDir = path.join(targetDir, 'skills', 'gsd');
assert.ok(fs.existsSync(categoryDir));
const skills = fs.readdirSync(categoryDir, { withFileTypes: true }).filter(e => e.isDirectory());
assert.ok(skills.length > 0);
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(categoryDir));
});
test('preserves non-GSD skill directories', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const custom = path.join(targetDir, 'skills', 'my-custom-skill');
fs.mkdirSync(custom, { recursive: true });
fs.writeFileSync(path.join(custom, 'SKILL.md'), '# custom\n');
uninstall(false, 'hermes');
assert.ok(fs.existsSync(path.join(custom, 'SKILL.md')));
});
test('removes engine directory', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
});
// ─── Section 4: No Claude references leak into non-Claude runtimes ────────────
for (const runtime of ['hermes', 'qwen']) {
describe(`no Claude references leak into ${runtime} install`, () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir(`gsd-${runtime}-refs-`);
previousCwd = process.cwd();
process.chdir(tmpDir);
install(false, runtime);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('skills contain no CLAUDE.md or Claude Code references', () => {
const rtDir = path.join(tmpDir, getDirName(runtime));
const skillsDir = path.join(rtDir, 'skills');
assert.ok(fs.existsSync(skillsDir));
const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md'));
assert.ok(skillFiles.length > 0);
const leaks = skillFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);
});
test('agents contain no CLAUDE.md or Claude Code references', () => {
const agentsDir = path.join(tmpDir, getDirName(runtime), 'agents');
assert.ok(fs.existsSync(agentsDir));
const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md'));
assert.ok(agentFiles.length > 0);
const leaks = agentFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);
});
test('full tree scan finds zero Claude references outside CHANGELOG.md', () => {
const rtDir = path.join(tmpDir, getDirName(runtime));
const allFiles = walk(rtDir).filter(f =>
(f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js')) &&
path.basename(f) !== 'CHANGELOG.md'
);
const leaks = allFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c) || /\.claude\//.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);
});
});
}
// ─── Section 5: Kilo-specific helpers ────────────────────────────────────────
describe('resolveKiloConfigPath', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('gsd-kilo-'); });
afterEach(() => { cleanup(tmpDir); });
test('prefers kilo.jsonc when present', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'kilo.jsonc'), '{\n}\n');
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.jsonc'));
});
test('falls back to kilo.json', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.json'));
});
});
describe('configureKiloPermissions', () => {
let tmpDir;
let configDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject('gsd-kilo-perms-');
configDir = path.join(tmpDir, '.config', 'kilo');
savedEnv = {
KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
};
process.env.KILO_CONFIG_DIR = configDir;
delete process.env.XDG_CONFIG_HOME;
});
afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v !== undefined) process.env[k] = v;
else delete process.env[k];
}
cleanup(tmpDir);
});
test('writes GSD permissions to kilo.json when config is missing', () => {
configureKiloPermissions(true);
const configPath = path.join(configDir, 'kilo.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('updates existing kilo.jsonc configs via JSONC parsing', () => {
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, 'kilo.jsonc');
fs.writeFileSync(configPath, '{\n // existing\n "permission": {\n "bash": "ask",\n },\n}\n');
configureKiloPermissions(true);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.bash, 'ask');
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('writes permissions to an explicit config dir argument', () => {
const explicitDir = path.join(tmpDir, 'custom-kilo-config');
configureKiloPermissions(true, explicitDir);
const configPath = path.join(explicitDir, 'kilo.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${explicitDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
});
describe('Kilo source integration assertions', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const updateWorkflowSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), 'utf8');
// #498: update.md's runtime/scope/config-dir resolution moved into the tested
// projection get-shit-done/bin/lib/update-context.cjs. Custom-config-dir
// detection (kilo.jsonc, KILO_CONFIG) is now asserted there.
const updateContextSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'update-context.cjs'), 'utf8');
test('--kilo flag parsing exists', () => {
assert.ok(src.includes("args.includes('--kilo')"));
});
test('runtimeMap has Kilo as option 11', () => {
assert.strictEqual(runtimeMap['11'], 'kilo');
});
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
const plain = stripAnsi(buildRuntimePromptText());
assert.ok(/\b11\)\s*Kilo\b/.test(plain));
assert.ok(plain.indexOf('11) Kilo') < plain.indexOf('OpenCode'));
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'));
});
test('finishInstall passes the actual config dir to Kilo permissions', () => {
assert.ok(src.includes('configureKiloPermissions(isGlobal, configDir);'));
});
test('uninstall cleans Kilo permissions from the resolved target dir', () => {
assert.ok(src.includes('const configPath = resolveKiloConfigPath(targetDir);'));
});
test('update workflow checks preferred custom config dirs', () => {
// update.md still derives the preferred config dir from execution_context…
assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR'));
// …and the custom-dir detection (kilo.jsonc config marker, KILO_CONFIG env)
// now lives in the tested update-context projection (#498).
assert.ok(updateContextSrc.includes('kilo.jsonc'));
assert.ok(updateContextSrc.includes('KILO_CONFIG'));
});
});