735 lines
29 KiB
JavaScript
735 lines
29 KiB
JavaScript
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')
|
||
const sddText = readFileSync(sddSkill, 'utf8')
|
||
assert.match(sddText, /name: sdd/)
|
||
assert.match(sddText, /\/approve/, 'the sdd skill must check the plan approval marker before dispatch')
|
||
assert.match(sddText, /approval\.md/, 'the sdd skill must name the approval marker file')
|
||
// T3.4: TDD order is part of the dispatch protocol: RED before the code.
|
||
assert.match(sddText, /падает \(RED\)/, 'the sdd skill must require a failing test (RED) before the code')
|
||
assert.match(sddText, /реализация без RED — `DONE_WITH_CONCERNS`/, 'a missing RED must degrade the report status')
|
||
|
||
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(devText, /Бандл гейтит цикл/)
|
||
assert.match(devText, /\/approve/, 'the DSH note must point at /approve')
|
||
assert.match(devText, /approval-gates/, 'the DSH note must name the approval-gates deny prefix')
|
||
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<!-- pin-marker: ${marker} -->\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<!-- install-source: ${marker} -->\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<!-- move-marker: second -->\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('a foreign git repo in $DSH_HOME/superpowers is never touched (I-1)', () => {
|
||
const work = tempDir()
|
||
try {
|
||
const home = join(work, 'home')
|
||
const pinned = makePinnedRepo(work)
|
||
const foreign = join(home, '.dsh', 'superpowers')
|
||
mkdirSync(foreign, { recursive: true })
|
||
git(foreign, ['init', '-b', 'main'])
|
||
git(foreign, ['config', 'user.email', 'dsh-harness@example.invalid'])
|
||
git(foreign, ['config', 'user.name', 'DSH Harness Test'])
|
||
write(join(foreign, 'witness.txt'), 'do not touch\n')
|
||
git(foreign, ['add', 'witness.txt'])
|
||
git(foreign, ['commit', '-m', 'foreign repo'])
|
||
const headBefore = git(foreign, ['rev-parse', 'HEAD'])
|
||
|
||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${pinned}` })
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 1, out)
|
||
assert.match(out, /not a superpowers clone/)
|
||
assert.match(out, /move it away or use --superpowers-from/)
|
||
assert.equal(readFileSync(join(foreign, 'witness.txt'), 'utf8'), 'do not touch\n')
|
||
assert.equal(git(foreign, ['rev-parse', 'HEAD']), headBefore, 'HEAD must not move')
|
||
assert.equal(existsSync(join(foreign, 'skills')), false, 'no skills tree may appear')
|
||
|
||
// --check agrees: the foreign repo is not reported as a source.
|
||
const check = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: `file://${pinned}` })
|
||
const checkOut = check.stdout + check.stderr
|
||
assert.equal(check.status, 0, checkOut)
|
||
assert.doesNotMatch(checkOut, /ok: superpowers source/)
|
||
} finally {
|
||
cleanup(work)
|
||
}
|
||
})
|
||
|
||
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('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2)', () => {
|
||
const work = tempDir()
|
||
try {
|
||
const home = join(work, 'home')
|
||
// An existing DSH_HOME outside the isolated HOME is the mixed state: the
|
||
// install would write outside the sandbox. The warning must not abort.
|
||
const sharedDsh = join(work, 'shared-dsh')
|
||
mkdirSync(home, { recursive: true })
|
||
mkdirSync(sharedDsh, { recursive: true })
|
||
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
|
||
const warn = `WARN: DSH_HOME=${sharedDsh} points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together`
|
||
|
||
const skills = runInstaller(home, ['--skills', '--superpowers-from', src], { DSH_HOME: sharedDsh })
|
||
const skillsOut = skills.stdout + skills.stderr
|
||
assert.equal(skills.status, 0, skillsOut)
|
||
assert.ok(skillsOut.includes(warn), skillsOut)
|
||
assert.ok(
|
||
existsSync(join(sharedDsh, 'skills', 'using-superpowers', 'SKILL.md')),
|
||
'the install still runs after the warning',
|
||
)
|
||
|
||
const roles = runInstaller(home, ['--roles'], { DSH_HOME: sharedDsh })
|
||
const rolesOut = roles.stdout + roles.stderr
|
||
assert.equal(roles.status, 0, rolesOut)
|
||
assert.ok(rolesOut.includes(warn), rolesOut)
|
||
assert.ok(existsSync(join(sharedDsh, 'roles', 'dev.md')), 'roles land in the warned DSH_HOME')
|
||
|
||
// --global overwrites AGENTS.md (after a backup): the destructive step must
|
||
// warn too (T3.1).
|
||
const global = runInstaller(home, ['--global'], { DSH_HOME: sharedDsh })
|
||
const globalOut = global.stdout + global.stderr
|
||
assert.equal(global.status, 0, globalOut)
|
||
assert.ok(globalOut.includes(warn), globalOut)
|
||
assert.ok(existsSync(join(sharedDsh, 'AGENTS.md')), 'AGENTS.md is installed after the warning')
|
||
|
||
// One run touching several steps warns exactly once (T3.2).
|
||
const combined = runInstaller(home, ['--skills', '--roles', '--superpowers-from', src], { DSH_HOME: sharedDsh })
|
||
const combinedOut = combined.stdout + combined.stderr
|
||
assert.equal(combined.status, 0, combinedOut)
|
||
assert.equal((combinedOut.match(/WARN: DSH_HOME=/g) ?? []).length, 1, combinedOut)
|
||
|
||
// Isolated together ($DSH_HOME=$HOME/.dsh): no warning.
|
||
const normal = runInstaller(home, ['--skills', '--superpowers-from', src])
|
||
const normalOut = normal.stdout + normal.stderr
|
||
assert.equal(normal.status, 0, normalOut)
|
||
assert.doesNotMatch(normalOut, /WARN: DSH_HOME=/)
|
||
} finally {
|
||
cleanup(work)
|
||
}
|
||
})
|
||
|
||
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)
|
||
}
|
||
})
|