495 lines
17 KiB
JavaScript
495 lines
17 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { spawnSync } from 'node:child_process'
|
||
import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
|
||
import { dirname, join } from 'node:path'
|
||
import test from 'node:test'
|
||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||
import { cleanup, git, tempDir, write } from './helpers.mjs'
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url))
|
||
const script = join(here, '..', 'install.sh')
|
||
|
||
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: join(home, 'no-superpowers-repo'),
|
||
}
|
||
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')
|
||
}
|
||
|
||
function installerEnv(home) {
|
||
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
|
||
delete env.DSH_SUPERPOWERS_SOURCE
|
||
delete env.DSH_SUPERPOWERS_REPO
|
||
// No test may reach GitHub: the git source defaults to a path that does not
|
||
// exist, and git tests opt in with a file:// fixture.
|
||
env.DSH_SUPERPOWERS_REPO = join(home, 'no-superpowers-repo')
|
||
return env
|
||
}
|
||
|
||
function makeSuperpowersRepo(home, name = 'superpowers-src') {
|
||
const repo = join(home, name)
|
||
mkdirSync(repo, { recursive: true })
|
||
git(repo, ['init', '-b', 'main'])
|
||
git(repo, ['config', 'user.email', 'test@example.invalid'])
|
||
git(repo, ['config', 'user.name', 'Test'])
|
||
makeSuperpowers(repo)
|
||
git(repo, ['add', '.'])
|
||
git(repo, ['commit', '-m', 'init'])
|
||
return repo
|
||
}
|
||
|
||
function gitUrl(repo) {
|
||
return pathToFileURL(repo).href
|
||
}
|
||
|
||
function runInstaller(home, args, extraEnv = {}) {
|
||
return spawnSync('bash', [script, ...args], {
|
||
env: { ...installerEnv(home), ...extraEnv },
|
||
encoding: 'utf8',
|
||
})
|
||
}
|
||
|
||
test('--skills takes superpowers from --superpowers-from in a clean HOME', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const src = makeSuperpowers(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, /copied 2 superpowers skills/)
|
||
assert.ok(run.stdout.includes(`source: ${join(src, 'skills')}`), 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 = makeSuperpowers(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 2 superpowers skills/)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('--superpowers-from accepts a path with spaces', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const src = makeSuperpowers(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 = makeSuperpowers(join(home, 'flag-source'), 'from-flag')
|
||
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
|
||
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'),
|
||
/from-flag/,
|
||
)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
|
||
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'),
|
||
/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'])
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 1, out)
|
||
assert.match(out, /FAIL: no superpowers source/)
|
||
assert.match(out, /no-superpowers-repo/)
|
||
assert.match(out, /pass --superpowers-from/)
|
||
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 keeps installed copies when the source is gone', () => {
|
||
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'])
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 0, out)
|
||
assert.match(out, /WARN: 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: '' })
|
||
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'])
|
||
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 warns about a manual non-git checkout', () => {
|
||
const home = tempDir()
|
||
try {
|
||
makeSuperpowers(join(home, '.dsh', 'superpowers'))
|
||
const run = runInstaller(home, ['--check'])
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 0, out)
|
||
assert.match(out, /not a git checkout; --skills will not update it/)
|
||
} 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'])
|
||
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)
|
||
}
|
||
})
|
||
|
||
test('--skills clones the git source on first run', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const repo = makeSuperpowersRepo(home)
|
||
const sha = git(repo, ['rev-parse', '--short', 'HEAD'])
|
||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) })
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 0, out)
|
||
assert.ok(existsSync(join(home, '.dsh', 'superpowers', '.git')), 'clone is missing')
|
||
assert.match(run.stdout, /copied 2 superpowers skills/)
|
||
assert.ok(run.stdout.includes(`git ${sha}`), out)
|
||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('--skills updates an existing clone to the new commit', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const repo = makeSuperpowersRepo(home)
|
||
const env = { DSH_SUPERPOWERS_REPO: gitUrl(repo) }
|
||
assert.equal(runInstaller(home, ['--skills'], env).status, 0)
|
||
|
||
write(join(repo, 'skills', 'new-skill', 'SKILL.md'), '---\nname: new-skill\n---\nbody\n')
|
||
git(repo, ['add', '.'])
|
||
git(repo, ['commit', '-m', 'add new-skill'])
|
||
const sha = git(repo, ['rev-parse', '--short', 'HEAD'])
|
||
|
||
const second = runInstaller(home, ['--skills'], env)
|
||
const out = second.stdout + second.stderr
|
||
assert.equal(second.status, 0, out)
|
||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'new-skill', 'SKILL.md')), 'clone was not updated')
|
||
assert.ok(second.stdout.includes(`git ${sha}`), out)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('an offline update keeps the cached clone', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const repo = makeSuperpowersRepo(home)
|
||
assert.equal(runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) }).status, 0)
|
||
|
||
const offline = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: join(home, 'gone') })
|
||
const out = offline.stdout + offline.stderr
|
||
assert.equal(offline.status, 0, out)
|
||
assert.match(out, /WARN: cannot update superpowers/)
|
||
assert.match(offline.stdout, /copied 2 superpowers skills/)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('a failed first clone leaves no checkout behind', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const run = runInstaller(home, ['--skills'])
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 1, out)
|
||
assert.equal(existsSync(join(home, '.dsh', 'superpowers')), false, 'broken clone must not stay')
|
||
assert.match(out, /FAIL: no superpowers source/)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('an explicit source never touches git', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
|
||
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
|
||
assert.equal(run.status, 0, run.stdout + run.stderr)
|
||
assert.equal(existsSync(join(home, '.dsh', 'superpowers')), false)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('a manual non-git checkout is used but not updated', () => {
|
||
const home = tempDir()
|
||
try {
|
||
makeSuperpowers(join(home, '.dsh', 'superpowers'))
|
||
const run = runInstaller(home, ['--skills'])
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 0, out)
|
||
assert.match(out, /not a git checkout/)
|
||
assert.match(run.stdout, /copied 2 superpowers skills/)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('--check reports the git sha of the managed checkout', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const repo = makeSuperpowersRepo(home)
|
||
const sha = git(repo, ['rev-parse', '--short', 'HEAD'])
|
||
assert.equal(runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) }).status, 0)
|
||
const run = runInstaller(home, ['--check'])
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 0, out)
|
||
assert.match(out, new RegExp(`ok: superpowers source \\(.*superpowers/skills, git ${sha}\\)`))
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|
||
|
||
test('--check never clones on an empty HOME', () => {
|
||
const home = tempDir()
|
||
try {
|
||
const repo = makeSuperpowersRepo(home)
|
||
const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) })
|
||
const out = run.stdout + run.stderr
|
||
assert.equal(run.status, 0, out)
|
||
assert.equal(existsSync(join(home, '.dsh', 'superpowers')), false, '--check must not clone')
|
||
assert.match(out, /WARN: no superpowers source/)
|
||
} finally {
|
||
cleanup(home)
|
||
}
|
||
})
|