import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' import { cleanup, git, tempDir, write } from './helpers.mjs' const here = dirname(fileURLToPath(import.meta.url)) const script = join(here, '..', 'install.sh') const testDir = dirname(fileURLToPath(import.meta.url)) const fixture = resolve(testDir, 'fixtures', 'superpowers-min') test('install.sh copies skills and roles instead of linking into ~/.claude', () => { const home = tempDir() try { const dshHome = join(home, '.dsh') const cache = join( home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers', '7.0.0', 'skills', ) write(join(cache, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') const env = { ...process.env, HOME: home, DSH_HOME: dshHome, DSH_SUPERPOWERS_REPO: 'file:///nonexistent' } delete env.DSH_SUPERPOWERS_SOURCE delete env.DSH_SUPERPOWERS_REF const run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' }) const first = run() assert.equal(first.status, 0, first.stderr) assert.match(first.stdout, /copied 2 superpowers skills/) assert.match(first.stdout, /copied 2 local skills/) assert.match(first.stdout, /installed 4 roles/) const skill = join(dshHome, 'skills', 'using-superpowers') assert.equal(lstatSync(skill).isSymbolicLink(), false) assert.equal(lstatSync(skill).isDirectory(), true) assert.match(readFileSync(join(skill, 'SKILL.md'), 'utf8'), /body/) const roleSkill = join(dshHome, 'skills', 'role', 'SKILL.md') assert.ok(existsSync(roleSkill), 'role skill was not installed') assert.match(readFileSync(roleSkill, 'utf8'), /name: role/) const sddSkill = join(dshHome, 'skills', 'sdd', 'SKILL.md') assert.ok(existsSync(sddSkill), 'sdd skill was not installed') assert.match(readFileSync(sddSkill, 'utf8'), /name: sdd/) for (const role of ['dev', 'admin', 'analyst', 'qa']) { assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`) } const devRole = join(dshHome, 'roles', 'dev.md') const devText = readFileSync(devRole, 'utf8') assert.match(devText, /В DSH-сессии исполнение плана идёт по skill `sdd`/) assert.match(devText, /`maxDepth: 2`/) assert.match(first.stdout, /role dsh notes/) assert.equal(countNotes(devText), 1, 'note must be inserted once') const second = run() assert.equal(second.status, 0, second.stderr) assert.equal(lstatSync(skill).isSymbolicLink(), false) assert.ok(existsSync(roleSkill)) assert.match(second.stdout, /role dsh notes/) assert.equal(countNotes(readFileSync(devRole, 'utf8')), 1, 'note must not duplicate') const check = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' }) assert.match(check.stdout, /ok: role dsh notes/) } finally { cleanup(home) } }) function countNotes(text) { return (text.match(/В DSH-сессии исполнение плана/g) ?? []).length } function makeSuperpowers(root, body = 'body') { for (const name of ['using-superpowers', 'brainstorming']) { write(join(root, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n${body}\n`) } return root } function makeLegacyCache(home) { const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers', '7.0.0') makeSuperpowers(root) return join(root, 'skills') } /** A local git repo carrying the reduced upstream checkout on tag v7.0.1. */ function makePinnedRepo(work, marker = null, name = 'upstream-repo') { const repo = join(work, name) cpSync(fixture, repo, { recursive: true }) if (marker) { const skill = join(repo, 'skills', 'using-superpowers', 'SKILL.md') write(skill, `${readFileSync(skill, 'utf8')}\n\n`) } try { git(repo, ['init', '-b', 'main']) } catch { git(repo, ['init']) git(repo, ['checkout', '-b', 'main']) } git(repo, ['config', 'user.email', 'dsh-harness@example.invalid']) git(repo, ['config', 'user.name', 'DSH Harness Test']) git(repo, ['add', '.']) git(repo, ['commit', '-m', 'superpowers fixture']) git(repo, ['tag', 'v7.0.1']) return repo } /** A full 15-skill checkout copy (explicit sources sync it in place). */ function fixtureSuperpowers(work, marker = null, root = join(work, 'src', 'superpowers')) { cpSync(fixture, root, { recursive: true }) if (marker) { const skill = join(root, 'skills', 'using-superpowers', 'SKILL.md') write(skill, `${readFileSync(skill, 'utf8')}\n\n`) } return root } function walk(dir, acc = []) { if (!existsSync(dir)) return acc for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name) acc.push(full) if (entry.isDirectory()) walk(full, acc) } return acc } function installerEnv(home) { const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') } delete env.DSH_SUPERPOWERS_SOURCE delete env.DSH_SUPERPOWERS_REPO delete env.DSH_SUPERPOWERS_REF return env } function runInstaller(home, args, extraEnv = {}) { return spawnSync('bash', [script, ...args], { env: { ...installerEnv(home), ...extraEnv }, encoding: 'utf8', }) } test('--skills clones the pinned ref, syncs, verifies and installs adapted skills', () => { const work = tempDir() try { const home = join(work, 'home') const repo = makePinnedRepo(work) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) assert.match(run.stdout, /synced \d+ files/) assert.match(run.stdout, /verified 15 skills/) assert.match(run.stdout, /copied 15 superpowers skills/) const skillFile = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') const text = readFileSync(skillFile, 'utf8') assert.match(text, /references\/dsh-tools\.md/) assert.doesNotMatch(text, /superpowers:/) assert.ok( existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'references', 'dsh-tools.md')), 'the DSH tool mapping must be installed', ) assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed') } finally { cleanup(work) } }) test('--skills offline without a source fails with the clone hint', () => { const home = tempDir() try { const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(run.stderr, /git clone --depth 1 --branch v7\.0\.1 file:\/\/\/nonexistent/) assert.match(out, /FAIL: no superpowers source/) assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) } finally { cleanup(home) } }) test('--skills re-run picks up a moved tag', () => { const work = tempDir() try { const home = join(work, 'home') const repo = makePinnedRepo(work) const env = { DSH_SUPERPOWERS_REPO: `file://${repo}` } const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') const first = runInstaller(home, ['--skills'], env) assert.equal(first.status, 0, first.stdout + first.stderr) assert.doesNotMatch(readFileSync(installed, 'utf8'), /move-marker: second/) const upstreamSkill = join(repo, 'skills', 'using-superpowers', 'SKILL.md') write(upstreamSkill, `${readFileSync(upstreamSkill, 'utf8')}\n\n`) git(repo, ['add', 'skills/using-superpowers/SKILL.md']) git(repo, ['commit', '--amend', '--no-edit']) git(repo, ['tag', '-f', 'v7.0.1']) const second = runInstaller(home, ['--skills'], env) assert.equal(second.status, 0, second.stdout + second.stderr) assert.match(readFileSync(installed, 'utf8'), /move-marker: second/) } finally { cleanup(work) } }) test('--skills updates an existing clone from DSH_SUPERPOWERS_REPO, not origin (T3.3)', () => { const work = tempDir() try { const home = join(work, 'home') const repoA = makePinnedRepo(work, 'from-a', 'upstream-a') const repoB = makePinnedRepo(work, 'from-b', 'upstream-b') const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoA}` }) assert.equal(first.status, 0, first.stdout + first.stderr) assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-a/) // Same ref, different repo: the update must come from the env repo, not // from the clone's origin. const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoB}` }) assert.equal(second.status, 0, second.stdout + second.stderr) assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/) assert.doesNotMatch(readFileSync(installed, 'utf8'), /pin-marker: from-a/) } finally { cleanup(work) } }) test('after a repo switch, an offline run still reuses the clone on the ref (T3.5)', () => { const work = tempDir() try { const home = join(work, 'home') const repoA = makePinnedRepo(work, 'from-a', 'upstream-a') const repoB = makePinnedRepo(work, 'from-b', 'upstream-b') const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoA}` }) assert.equal(first.status, 0, first.stdout + first.stderr) // Online switch: the clone moves to repoB's v7.0.1 (T3.3 behavior). const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoB}` }) assert.equal(second.status, 0, second.stdout + second.stderr) assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/) // Offline afterwards: the local tag must have moved with the fetch, so // the on-ref reuse succeeds with a WARN instead of a hard FAIL. const third = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = third.stdout + third.stderr assert.equal(third.status, 0, out) assert.match(out, /offline; reusing the existing clone/) assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/) } finally { cleanup(work) } }) test('--skills removes a half-cloned dir after a failed fresh clone (RF5)', () => { const home = tempDir() try { const cloneDir = join(home, '.dsh', 'superpowers') mkdirSync(cloneDir, { recursive: true }) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.equal(existsSync(cloneDir), false, 'half-cloned dir must be removed') } finally { cleanup(home) } }) test('--skills reuses an on-ref clone when offline (WARN)', () => { const work = tempDir() try { const home = join(work, 'home') const repo = makePinnedRepo(work) const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) assert.equal(first.status, 0, first.stdout + first.stderr) // Go offline for real: the clone's origin must become unreachable, not // just the DSH_SUPERPOWERS_REPO env (an existing clone fetches origin). rmSync(repo, { recursive: true, force: true }) const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = second.stdout + second.stderr assert.equal(second.status, 0, out) assert.match(out, /offline; reusing the existing clone/) assert.match(second.stdout, /copied 15 superpowers skills/) } finally { cleanup(work) } }) test('--skills leaves no staging garbage and records the ref in .sync-meta.json', () => { const work = tempDir() try { const home = join(work, 'home') const repo = makePinnedRepo(work) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) assert.equal(run.status, 0, run.stdout + run.stderr) const cloneDir = join(home, '.dsh', 'superpowers') const garbage = walk(cloneDir).filter((p) => p.includes('.tmp-')) assert.deepEqual(garbage, [], `staging garbage left behind: ${garbage.join(', ')}`) const meta = JSON.parse(readFileSync(join(cloneDir, 'skills.synced', '.sync-meta.json'), 'utf8')) assert.equal(meta.ref, 'v7.0.1') } finally { cleanup(work) } }) test('explicit syncs stage outside the pinned clone; a later default run clones fresh (T3.1)', () => { const work = tempDir() try { const home = join(work, 'home') const repo = makePinnedRepo(work) const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) const first = runInstaller(home, ['--skills', '--superpowers-from', src]) assert.equal(first.status, 0, first.stdout + first.stderr) assert.ok( existsSync(join(home, '.dsh', '.skills.synced', 'using-superpowers', 'SKILL.md')), 'explicit staging lives outside $DSH_HOME/superpowers', ) assert.equal( existsSync(join(home, '.dsh', 'superpowers')), false, 'an explicit run must not create the pinned-clone dir', ) const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) const out = second.stdout + second.stderr assert.equal(second.status, 0, out) assert.doesNotMatch(out, /no usable \.git/) assert.match(second.stdout, /copied 15 superpowers skills/) } finally { cleanup(work) } }) test('a bare skills dir without a checkout root is invalid for --skills and --check alike (T3.2)', () => { const home = tempDir() try { const bare = join(home, 'my-skills') write(join(bare, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') const skills = runInstaller(home, ['--skills', '--superpowers-from', bare]) const check = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_SOURCE: bare }) for (const run of [skills, check]) { const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /checkout root or its skills\/ directory/) } assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) } finally { cleanup(home) } }) test('--check is offline and reports the configured ref', () => { const home = tempDir() try { const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) assert.match(run.stdout, /ref v7\.0\.1/) assert.doesNotMatch(out, /fatal:/, '--check must not attempt to clone') } finally { cleanup(home) } }) test('.claude/code-paths lists dsh/', () => { const codePaths = resolve(testDir, '..', '..', '.claude', 'code-paths') const text = readFileSync(codePaths, 'utf8') assert.ok(text.split('\n').includes('dsh/'), 'code-paths must gate dsh/ under require-plan (D-5)') }) test('--skills syncs an explicit --superpowers-from source in a clean HOME', () => { const home = tempDir() try { const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) const run = runInstaller(home, ['--skills', '--superpowers-from', src]) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) assert.match(run.stdout, /synced \d+ files/) assert.match(run.stdout, /verified 15 skills/) assert.match(run.stdout, /copied 15 superpowers skills/) assert.ok(run.stdout.includes(`source: ${src}`), out) assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))) assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md'))) assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md'))) assert.equal(existsSync(join(home, '.claude')), false, 'clean HOME must stay clean') assert.doesNotMatch(out, /legacy/) } finally { cleanup(home) } }) test('--superpowers-from accepts the skills directory itself', () => { const home = tempDir() try { const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) const run = runInstaller(home, ['--skills', '--superpowers-from', join(src, 'skills')]) assert.equal(run.status, 0, run.stdout + run.stderr) assert.match(run.stdout, /copied 15 superpowers skills/) } finally { cleanup(home) } }) test('--superpowers-from accepts a path with spaces', () => { const home = tempDir() try { const src = fixtureSuperpowers(home, null, join(home, 'super powers')) const run = runInstaller(home, ['--skills', '--superpowers-from', src]) assert.equal(run.status, 0, run.stdout + run.stderr) assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))) } finally { cleanup(home) } }) test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => { const home = tempDir() try { const fromFlag = fixtureSuperpowers(home, 'from-flag', join(home, 'flag-source')) const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source')) const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], { DSH_SUPERPOWERS_SOURCE: fromEnv, }) assert.equal(run.status, 0, run.stdout + run.stderr) assert.match( readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'), /install-source: from-flag/, ) } finally { cleanup(home) } }) test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => { const home = tempDir() try { const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source')) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: fromEnv }) assert.equal(run.status, 0, run.stdout + run.stderr) assert.match( readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'), /install-source: from-env/, ) } finally { cleanup(home) } }) test('--skills fails when there is no source and nothing installed', () => { const home = tempDir() try { const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/) assert.match(out, /git clone --depth 1 --branch v7\.0\.1 file:\/\/\/nonexistent/) assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed') assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md')), 'local skills still installed') assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) } finally { cleanup(home) } }) test('--skills offline without a source fails but keeps the installed copies', () => { const home = tempDir() try { const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') write(installed, '---\nname: using-superpowers\n---\ninstalled\n') const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/) assert.match(readFileSync(installed, 'utf8'), /installed/) } finally { cleanup(home) } }) test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => { const home = tempDir() try { const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: '', DSH_SUPERPOWERS_REPO: 'file:///nonexistent', }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/) assert.doesNotMatch(out, /explicit superpowers source/) } finally { cleanup(home) } }) test('an invalid explicit source fails without touching the legacy cache', () => { const home = tempDir() try { makeLegacyCache(home) const empty = join(home, 'not-superpowers') const run = runInstaller(home, ['--skills', '--superpowers-from', empty]) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: explicit superpowers source is invalid: .* has no using-superpowers\/SKILL\.md/) assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false) } finally { cleanup(home) } }) test('the legacy plugin cache still works and warns', () => { const home = tempDir() try { makeLegacyCache(home) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) assert.match(out, /WARN: legacy superpowers source/) assert.match(run.stdout, /copied 2 superpowers skills/) assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))) } finally { cleanup(home) } }) test('--check reports the pinned source', () => { const home = tempDir() try { const pinned = join(home, '.dsh', 'superpowers') makeSuperpowers(pinned) const run = runInstaller(home, ['--check']) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) assert.match(out, /ok: superpowers source \(.*superpowers\/skills\)/) } finally { cleanup(home) } }) test('--check fails on an invalid explicit source', () => { const home = tempDir() try { const empty = join(home, 'not-superpowers') const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_SOURCE: empty }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: explicit superpowers source/) } finally { cleanup(home) } }) test('--global installs DSH role paths and explains the instruction format', () => { const home = tempDir() try { const globalSource = join(here, '..', '..', 'global', 'CLAUDE.md') const before = readFileSync(globalSource, 'utf8') const run = runInstaller(home, ['--global']) assert.equal(run.status, 0, run.stdout + run.stderr) const agents = readFileSync(join(home, '.dsh', 'AGENTS.md'), 'utf8') assert.match(agents, /~\/\.dsh\/roles\/dev\.md/) assert.doesNotMatch(agents, /~\/\.claude/) assert.match(agents, /superpowers skills are ~\/\.dsh\/skills/) assert.match(agents, /instruction-file format of this harness/) assert.match(agents, /does not imply a Claude Code runtime/) assert.equal(readFileSync(globalSource, 'utf8'), before, 'global/CLAUDE.md must stay untouched') const second = runInstaller(home, ['--global']) assert.equal(second.status, 0, second.stdout + second.stderr) assert.match(second.stdout, /backup: /) } finally { cleanup(home) } }) test('a legacy cache without using-superpowers is not a source', () => { const home = tempDir() try { const cache = join( home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers', '7.0.0', 'skills', ) write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/) assert.doesNotMatch(out, /copied 0 superpowers/) assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false) } finally { cleanup(home) } }) test('an empty --superpowers-from is rejected, not ignored', () => { const home = tempDir() try { makeLegacyCache(home) for (const args of [['--skills', '--superpowers-from='], ['--skills', '--superpowers-from', '']]) { const run = runInstaller(home, args) const out = run.stdout + run.stderr assert.equal(run.status, 2, out) assert.match(out, /needs a non-empty directory/) assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) } } finally { cleanup(home) } })