import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'node:fs' import { dirname, join } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' import { cleanup, tempDir, write } from './helpers.mjs' const here = dirname(fileURLToPath(import.meta.url)) const script = join(here, '..', 'install.sh') const root = join(here, '..', '..') const agentsSkills = join(root, '.agents', 'skills') const NAMES = [ 'openspec-apply-change', 'openspec-archive-change', 'openspec-explore', 'openspec-propose', 'openspec-sync-specs', 'openspec-update-change', ] test('repository ships six agents skills for DSH', () => { assert.equal(readFileSync(join(agentsSkills, '.openspec-target'), 'utf8').trim(), 'agents') for (const name of NAMES) { const file = join(agentsSkills, name, 'SKILL.md') assert.ok(existsSync(file), `${name}/SKILL.md is missing`) const text = readFileSync(file, 'utf8') assert.match(text, new RegExp(`^name: ${name}$`, 'm'), `${name}: frontmatter name must match the dir`) assert.match(text, /description: .*openspec/i, `${name}: description must mention openspec`) } }) test('install.sh --check reports the openspec CLI and the agents skills', () => { const home = tempDir() try { const bin = join(home, 'bin') write(join(bin, 'openspec'), '#!/bin/sh\n[ "$1" = "--version" ] && echo 1.13.1\nexit 0\n') chmodSync(join(bin, 'openspec'), 0o755) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` } const run = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' }) assert.equal(run.status, 0, run.stderr) assert.match(run.stdout, /ok: openspec 1\.13\.1/) assert.match(run.stdout, /ok: openspec agents skills \(/) } finally { cleanup(home) } }) test('install.sh --check warns but does not fail without the openspec CLI', () => { const home = tempDir() try { const bin = join(home, 'bin') mkdirSync(bin, { recursive: true }) symlinkSync(process.execPath, join(bin, 'node')) // openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, } const run = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' }) assert.equal(run.status, 0, run.stderr) assert.match(run.stdout + run.stderr, /WARN: openspec not found/) } finally { cleanup(home) } }) function fakeOpenspec(dir, log) { const bin = join(dir, 'bin') write(join(bin, 'openspec'), `#!/bin/sh\necho "$PWD $@" >> ${JSON.stringify(log)}\nexit 0\n`) chmodSync(join(bin, 'openspec'), 0o755) return bin } test('--openspec adds the agents adapter to an existing OpenSpec project', () => { const home = tempDir() try { const project = join(home, 'project') mkdirSync(join(project, 'openspec'), { recursive: true }) // macOS: os.tmpdir() идёт через симлинк /var -> /private/var, а $PWD в шелле — уже реальный путь const realProject = realpathSync(project) const log = join(home, 'calls.log') const bin = fakeOpenspec(home, log) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` } const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) assert.equal(run.status, 0, run.stderr) assert.match(run.stdout, /ok: openspec agents adapter in /) assert.match(readFileSync(log, 'utf8'), new RegExp(`^${realProject} init --tools agents$`, 'm')) } finally { cleanup(home) } }) test('--openspec refuses a project without an openspec root', () => { const home = tempDir() try { const project = join(home, 'plain') mkdirSync(project, { recursive: true }) const log = join(home, 'calls.log') const bin = fakeOpenspec(home, log) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` } const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) assert.notEqual(run.status, 0) assert.match(run.stdout + run.stderr, /has no openspec\/ root/) assert.equal(existsSync(log), false, 'openspec must not run in a non-OpenSpec project') assert.equal(existsSync(join(project, 'openspec')), false) } finally { cleanup(home) } }) test('--openspec fails when openspec init fails', () => { const home = tempDir() try { const project = join(home, 'project') mkdirSync(join(project, 'openspec'), { recursive: true }) const bin = join(home, 'bin') write(join(bin, 'openspec'), '#!/bin/sh\nexit 1\n') chmodSync(join(bin, 'openspec'), 0o755) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` } const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) assert.notEqual(run.status, 0) assert.doesNotMatch(run.stdout, /ok: openspec agents adapter/) assert.match(run.stderr, /FAIL/) } finally { cleanup(home) } }) test('--openspec fails without the openspec CLI', () => { const home = tempDir() try { const project = join(home, 'project') mkdirSync(join(project, 'openspec'), { recursive: true }) const bin = join(home, 'bin') mkdirSync(bin, { recursive: true }) symlinkSync(process.execPath, join(bin, 'node')) // openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, } const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) assert.notEqual(run.status, 0) assert.match(run.stdout + run.stderr, /FAIL: openspec CLI not found/) } finally { cleanup(home) } })