Phase 2 of #3660 / ADR-3660. Routes both lifecycle verbs through the Runtime Artifact Layout Module landed in Phase 1 (#3663): - Add installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) and uninstallRuntimeArtifacts(runtime, configDir, scope) as the public orchestrators. Both pre-prune stale gsd-* entries before staged copy; installRuntimeArtifacts brackets the prune+copy with preserveUserArtifacts / restoreUserArtifacts so user-owned content (e.g. gsd-dev-preferences) survives wipe-and-replace for claude/qwen/hermes runtimes. - Add applyRuntimeContentRewritesInPlace as the per-runtime path/branding post-stage step (preserves byte-output equivalence with the legacy copyCommandsAs* pipeline, including Qwen/Hermes branding rewrites). - Add _copyStaged, _removeGsdEntries kind-aware filesystem helpers. - Add _runLegacyInstallMigrations, _runLegacyUninstallCleanup as thin dispatchers over existing ADR-0008 legacy migrations (Hermes flat->nested per #2841, dev-preferences-as-skill per #2973). For Hermes, also clean up the intermediate skills/gsd/gsd-*/ layout that pre-Phase-2 installs left on disk. - Delete the 9 copyCommandsAs*Skills functions (Codex / Cursor / Windsurf / Trae / CodeBuddy / Copilot / Claude / Antigravity / Augment) and the _copyCommandsAsSkillsViaConverter helper. All test entry points migrated to call installRuntimeArtifacts directly through the unified seam. - Collapse the 9-branch uninstall ladder to one uninstallRuntimeArtifacts call plus preserved non-layout side-effects (Codex TOML, Copilot instructions, hooks). - Unify install dispatcher: a single _isSkillsRuntime gate routes all 11 skills runtimes through installRuntimeArtifacts for both full and core/ minimal profiles. Removes 11 per-runtime if-else branches (3 minimal-mode shim branches + 8 dead after-the-gate branches). Net delta on bin/install.js: 11,495 -> 11,174 (-321 LOC). New tests: - tests/install-uninstall-layout-loop.test.cjs (34 tests) - per-runtime fixture assertions on install/uninstall/legacy-migration ordering. - tests/install-hermes-regressions.test.cjs (6 tests) - covers the six defects surfaced by iterative review: Hermes upgrade leaves stale dirs, --hermes --profile=core fall-through, --qwen --profile=core fall-through, minimal-mode dev-preferences migration skipped (Hermes/Qwen/Claude-global), and ordering bug in _runLegacyInstallMigrations. Existing tests (10,038 prior + 40 new) all green: 10,078/10,078 pass. Refs #3664 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
422 lines
18 KiB
JavaScript
422 lines
18 KiB
JavaScript
// 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.
|
|
|
|
/**
|
|
* GSD Tools Tests - Antigravity Install Plumbing
|
|
*
|
|
* Tests for Antigravity runtime directory resolution, config paths,
|
|
* content conversion functions, and integration with the multi-runtime installer.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
|
|
|
const {
|
|
getDirName,
|
|
getGlobalDir,
|
|
getConfigDirFromHome,
|
|
convertClaudeToAntigravityContent,
|
|
convertClaudeCommandToAntigravitySkill,
|
|
convertClaudeAgentToAntigravityAgent,
|
|
writeManifest,
|
|
installRuntimeArtifacts,
|
|
} = require('../bin/install.js');
|
|
|
|
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
|
|
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
|
|
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
|
const _manifest = loadSkillsManifest();
|
|
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
|
|
|
// ─── getDirName ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('getDirName (Antigravity)', () => {
|
|
test('returns .agent for antigravity', () => {
|
|
assert.strictEqual(getDirName('antigravity'), '.agent');
|
|
});
|
|
|
|
test('does not break existing runtimes', () => {
|
|
assert.strictEqual(getDirName('claude'), '.claude');
|
|
assert.strictEqual(getDirName('opencode'), '.opencode');
|
|
assert.strictEqual(getDirName('gemini'), '.gemini');
|
|
assert.strictEqual(getDirName('kilo'), '.kilo');
|
|
assert.strictEqual(getDirName('codex'), '.codex');
|
|
assert.strictEqual(getDirName('copilot'), '.github');
|
|
});
|
|
});
|
|
|
|
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
|
|
|
|
describe('getGlobalDir (Antigravity)', () => {
|
|
let savedEnv;
|
|
|
|
beforeEach(() => {
|
|
savedEnv = process.env.ANTIGRAVITY_CONFIG_DIR;
|
|
delete process.env.ANTIGRAVITY_CONFIG_DIR;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedEnv !== undefined) {
|
|
process.env.ANTIGRAVITY_CONFIG_DIR = savedEnv;
|
|
} else {
|
|
delete process.env.ANTIGRAVITY_CONFIG_DIR;
|
|
}
|
|
});
|
|
|
|
test('returns ~/.gemini/antigravity by default', () => {
|
|
const result = getGlobalDir('antigravity');
|
|
assert.strictEqual(result, path.join(os.homedir(), '.gemini', 'antigravity'));
|
|
});
|
|
|
|
test('respects ANTIGRAVITY_CONFIG_DIR env var', () => {
|
|
const customDir = path.join(os.homedir(), 'custom-ag');
|
|
process.env.ANTIGRAVITY_CONFIG_DIR = customDir;
|
|
const result = getGlobalDir('antigravity');
|
|
assert.strictEqual(result, customDir);
|
|
});
|
|
|
|
test('explicit config-dir overrides env var', () => {
|
|
process.env.ANTIGRAVITY_CONFIG_DIR = path.join(os.homedir(), 'from-env');
|
|
const explicit = path.join(os.homedir(), 'explicit-ag');
|
|
const result = getGlobalDir('antigravity', explicit);
|
|
assert.strictEqual(result, explicit);
|
|
});
|
|
|
|
test('does not change Claude Code global dir', () => {
|
|
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
|
|
});
|
|
});
|
|
|
|
// ─── getConfigDirFromHome ───────────────────────────────────────────────────────
|
|
|
|
describe('getConfigDirFromHome (Antigravity)', () => {
|
|
test('returns .agent for local installs', () => {
|
|
assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agent'");
|
|
});
|
|
|
|
test('returns .gemini, antigravity for global installs', () => {
|
|
assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'");
|
|
});
|
|
|
|
test('does not change other runtimes', () => {
|
|
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
|
|
assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'");
|
|
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
|
|
assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'");
|
|
});
|
|
});
|
|
|
|
// ─── convertClaudeToAntigravityContent ─────────────────────────────────────────
|
|
|
|
describe('convertClaudeToAntigravityContent', () => {
|
|
describe('global install path replacements', () => {
|
|
test('replaces ~/. claude/ with ~/.gemini/antigravity/', () => {
|
|
const input = 'See ~/.claude/get-shit-done/workflows/';
|
|
const result = convertClaudeToAntigravityContent(input, true);
|
|
assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/workflows/'), result);
|
|
assert.ok(!result.includes('~/.claude/'), result);
|
|
});
|
|
|
|
test('replaces $HOME/.claude/ with $HOME/.gemini/antigravity/', () => {
|
|
const input = 'path.join($HOME/.claude/get-shit-done)';
|
|
const result = convertClaudeToAntigravityContent(input, true);
|
|
assert.ok(result.includes('$HOME/.gemini/antigravity/'), result);
|
|
assert.ok(!result.includes('$HOME/.claude/'), result);
|
|
});
|
|
});
|
|
|
|
describe('local install path replacements', () => {
|
|
test('replaces ~/.claude/ with .agent/ for local installs', () => {
|
|
const input = 'See ~/.claude/get-shit-done/';
|
|
const result = convertClaudeToAntigravityContent(input, false);
|
|
assert.ok(result.includes('.agent/get-shit-done/'), result);
|
|
assert.ok(!result.includes('~/.claude/'), result);
|
|
});
|
|
|
|
test('replaces ./.claude/ with ./.agent/', () => {
|
|
const input = 'path ./.claude/hooks/gsd-check-update.js';
|
|
const result = convertClaudeToAntigravityContent(input, false);
|
|
assert.ok(result.includes('./.agent/hooks/'), result);
|
|
assert.ok(!result.includes('./.claude/'), result);
|
|
});
|
|
|
|
test('replaces .claude/ with .agent/', () => {
|
|
const input = 'node .claude/hooks/gsd-statusline.js';
|
|
const result = convertClaudeToAntigravityContent(input, false);
|
|
assert.ok(result.includes('.agent/hooks/gsd-statusline.js'), result);
|
|
assert.ok(!result.includes('.claude/'), result);
|
|
});
|
|
});
|
|
|
|
describe('command name conversion', () => {
|
|
test('converts /gsd:command to /gsd-command', () => {
|
|
const input = 'Run /gsd:new-project to start';
|
|
const result = convertClaudeToAntigravityContent(input, true);
|
|
assert.ok(result.includes('/gsd-new-project'), result);
|
|
assert.ok(!result.includes('gsd:'), result);
|
|
});
|
|
|
|
test('converts all gsd: references', () => {
|
|
const input = '/gsd:plan-phase and /gsd:execute-phase';
|
|
const result = convertClaudeToAntigravityContent(input, false);
|
|
assert.ok(result.includes('/gsd-plan-phase'), result);
|
|
assert.ok(result.includes('/gsd-execute-phase'), result);
|
|
});
|
|
});
|
|
|
|
test('does not modify unrelated content', () => {
|
|
const input = 'This is a plain text description with no paths.';
|
|
const result = convertClaudeToAntigravityContent(input, false);
|
|
assert.strictEqual(result, input);
|
|
});
|
|
});
|
|
|
|
// ─── convertClaudeCommandToAntigravitySkill ─────────────────────────────────────
|
|
|
|
describe('convertClaudeCommandToAntigravitySkill', () => {
|
|
const claudeCommand = `---
|
|
name: gsd:new-project
|
|
description: Initialize a new GSD project with requirements and roadmap
|
|
argument-hint: "[project-name]"
|
|
allowed-tools:
|
|
- Read
|
|
- Write
|
|
- Bash
|
|
- Agent
|
|
---
|
|
|
|
Initialize new project at ~/.claude/get-shit-done/workflows/new-project.md
|
|
`;
|
|
|
|
test('produces name and description only in frontmatter', () => {
|
|
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false);
|
|
const fm = parseFrontmatter(result);
|
|
assert.equal(fm.name, 'gsd-new-project', result);
|
|
assert.equal(fm.description, 'Initialize a new GSD project with requirements and roadmap', result);
|
|
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
|
|
assert.ok(!('argument-hint' in fm), 'no argument-hint field');
|
|
});
|
|
|
|
test('applies path replacement in body', () => {
|
|
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false);
|
|
assert.ok(result.includes('.agent/get-shit-done/'), result);
|
|
assert.ok(!result.includes('~/.claude/'), result);
|
|
});
|
|
|
|
test('uses provided skillName for name field', () => {
|
|
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-custom-name', false);
|
|
assert.ok(result.includes('name: gsd-custom-name'), result);
|
|
});
|
|
|
|
test('converts gsd: command references in body', () => {
|
|
const content = `---
|
|
name: test
|
|
description: test skill
|
|
---
|
|
Run /gsd:new-project to get started.
|
|
`;
|
|
const result = convertClaudeCommandToAntigravitySkill(content, 'gsd-test', false);
|
|
assert.ok(result.includes('/gsd-new-project'), result);
|
|
assert.ok(!result.includes('gsd:'), result);
|
|
});
|
|
|
|
test('returns unchanged content when no frontmatter', () => {
|
|
const noFm = 'Just some text without frontmatter.';
|
|
const result = convertClaudeCommandToAntigravitySkill(noFm, 'gsd-test', false);
|
|
// Path replacements still apply, but no frontmatter transformation
|
|
assert.ok(!result.startsWith('---'), result);
|
|
});
|
|
});
|
|
|
|
// ─── convertClaudeAgentToAntigravityAgent ──────────────────────────────────────
|
|
|
|
describe('convertClaudeAgentToAntigravityAgent', () => {
|
|
const claudeAgent = `---
|
|
name: gsd-executor
|
|
description: Executes GSD plans with atomic commits
|
|
tools: Read, Write, Edit, Bash, Glob, Grep, Task
|
|
color: blue
|
|
---
|
|
|
|
Execute plans from ~/.claude/get-shit-done/workflows/execute-phase.md
|
|
`;
|
|
|
|
test('preserves name and description', () => {
|
|
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
|
|
const fm = parseFrontmatter(result);
|
|
assert.equal(fm.name, 'gsd-executor', result);
|
|
assert.equal(fm.description, 'Executes GSD plans with atomic commits', result);
|
|
});
|
|
|
|
test('maps Claude tools to Gemini tool names', () => {
|
|
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
|
|
// Read → read_file, Bash → run_shell_command
|
|
assert.ok(result.includes('read_file'), result);
|
|
assert.ok(result.includes('run_shell_command'), result);
|
|
// Original Claude names should not appear in tools line
|
|
const fmEnd = result.indexOf('---', 3);
|
|
const frontmatter = result.slice(0, fmEnd);
|
|
assert.ok(!frontmatter.includes('tools: Read,'), frontmatter);
|
|
});
|
|
|
|
test('preserves color field', () => {
|
|
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
|
|
assert.ok(result.includes('color: blue'), result);
|
|
});
|
|
|
|
test('applies path replacement in body', () => {
|
|
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
|
|
assert.ok(result.includes('.agent/get-shit-done/'), result);
|
|
assert.ok(!result.includes('~/.claude/'), result);
|
|
});
|
|
|
|
test('uses global path for global installs', () => {
|
|
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, true);
|
|
assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/'), result);
|
|
});
|
|
|
|
test('excludes Task tool (filtered by convertGeminiToolName)', () => {
|
|
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
|
|
// Task is excluded by convertGeminiToolName (returns null for Task)
|
|
const fmEnd = result.indexOf('---', 3);
|
|
const frontmatter = result.slice(0, fmEnd);
|
|
assert.ok(!frontmatter.includes('Task'), frontmatter);
|
|
});
|
|
});
|
|
|
|
// ─── installRuntimeArtifacts (antigravity integration) ────────────────────────
|
|
|
|
describe('installRuntimeArtifacts (antigravity integration)', () => {
|
|
// Pivoted from copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false) shim to
|
|
// installRuntimeArtifacts('antigravity', configDir, 'local'|'global', resolvedProfileFull).
|
|
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
|
// stageSkillsForRuntimeAsSkills does NOT recurse into subdirectories; the real
|
|
// commands/gsd/ directory has no subdirs, so subdir-handling is not a production path.
|
|
let configDir;
|
|
|
|
beforeEach(() => {
|
|
configDir = createTempDir('gsd-ag-test-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('creates skills directory', () => {
|
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
|
assert.ok(fs.existsSync(path.join(configDir, 'skills')));
|
|
});
|
|
|
|
test('creates one skill directory per command with SKILL.md', () => {
|
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
|
const skillsDir = path.join(configDir, 'skills');
|
|
const skillDir = path.join(skillsDir, 'gsd-new-project');
|
|
assert.ok(fs.existsSync(skillDir), 'skill dir should exist');
|
|
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist');
|
|
});
|
|
|
|
// NOTE: 'handles subdirectory commands with prefixed names' (gsd-subdir-sub-command) is
|
|
// DELETED. stageSkillsForRuntimeAsSkills processes only flat .md files in commands/gsd/;
|
|
// the real commands/gsd/ directory contains no subdirectories. This test had no production
|
|
// code path and cannot be expressed through the installRuntimeArtifacts seam.
|
|
|
|
test('SKILL.md has minimal frontmatter (name + description only)', () => {
|
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
|
|
const fm = parseFrontmatter(content);
|
|
assert.equal(fm.name, 'gsd-new-project', content);
|
|
assert.ok(fm.description, 'description field is present');
|
|
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
|
|
});
|
|
|
|
test('SKILL.md body has paths converted for local install', () => {
|
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
|
|
// gsd: → gsd- conversion
|
|
assert.ok(!content.includes('gsd:'), content);
|
|
});
|
|
|
|
test('removes old gsd-* skill dirs before reinstalling', () => {
|
|
const skillsDir = path.join(configDir, 'skills');
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
// Stale GSD-managed dir must be pruned
|
|
const staleDir = path.join(skillsDir, 'gsd-old-stale-skill');
|
|
fs.mkdirSync(staleDir, { recursive: true });
|
|
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
|
// Non-GSD dir should survive
|
|
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
|
fs.mkdirSync(otherDir, { recursive: true });
|
|
|
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
|
|
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-new-project')), 'real skill dir written');
|
|
assert.ok(!fs.existsSync(staleDir), 'stale gsd-* dir removed by pre-prune');
|
|
});
|
|
|
|
test('does not remove non-gsd skill dirs', () => {
|
|
// Create a non-GSD skill dir before install
|
|
const skillsDir = path.join(configDir, 'skills');
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
|
fs.mkdirSync(otherDir, { recursive: true });
|
|
|
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
|
|
|
assert.ok(fs.existsSync(otherDir), 'non-GSD skill dir should be preserved');
|
|
});
|
|
});
|
|
|
|
// ─── writeManifest (Antigravity) ───────────────────────────────────────────────
|
|
|
|
describe('writeManifest (Antigravity)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-manifest-ag-');
|
|
// Create minimal structure
|
|
const skillsDir = path.join(tmpDir, 'skills', 'gsd-help');
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '---\nname: gsd-help\ndescription: Help\n---\n');
|
|
const gsdDir = path.join(tmpDir, 'get-shit-done');
|
|
fs.mkdirSync(gsdDir, { recursive: true });
|
|
fs.writeFileSync(path.join(gsdDir, 'VERSION'), '1.0.0');
|
|
const agentsDir = path.join(tmpDir, 'agents');
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '---\nname: gsd-executor\n---\n');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('writes manifest JSON file', () => {
|
|
writeManifest(tmpDir, 'antigravity');
|
|
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
|
|
assert.ok(fs.existsSync(manifestPath), 'manifest file should exist');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
assert.ok(manifest.version, 'should have version');
|
|
assert.ok(manifest.files, 'should have files');
|
|
});
|
|
|
|
test('manifest includes skills in skills/ directory', () => {
|
|
writeManifest(tmpDir, 'antigravity');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
|
|
const skillFiles = Object.keys(manifest.files).filter(f => f.startsWith('skills/'));
|
|
assert.ok(skillFiles.length > 0, 'should have skill files in manifest');
|
|
});
|
|
|
|
test('manifest includes agent files', () => {
|
|
writeManifest(tmpDir, 'antigravity');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
|
|
const agentFiles = Object.keys(manifest.files).filter(f => f.startsWith('agents/'));
|
|
assert.ok(agentFiles.length > 0, 'should have agent files in manifest');
|
|
});
|
|
});
|