diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9298900..58eb1f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,13 @@ jobs: - uses: actions/setup-python@v6.2.0 with: python-version: "3.11" + - uses: actions/setup-node@v6.4.0 + with: + node-version: "20" - name: Install package run: python -m pip install -e . pytest + - name: Run Node installer tests + run: npm test - name: Run tests + # Node must be on PATH first: the slug-parity test shells out to it. run: pytest -q diff --git a/bin/install.js b/bin/install.js index 2d5e486..c5878f4 100644 --- a/bin/install.js +++ b/bin/install.js @@ -25,24 +25,11 @@ const child_process = require('child_process'); const REPO = 'derek-palmer/codeforerunner'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; -// All skill slugs written during a local install. -const TASK_SKILL_SLUGS = [ - 'codeforerunner', - 'forerunner-api-docs', - 'forerunner-arch-review', - 'forerunner-audit', - 'forerunner-changelog', - 'forerunner-check', - 'forerunner-diagrams', - 'forerunner-flows', - 'forerunner-init', - 'forerunner-readme', - 'forerunner-review', - 'forerunner-scan', - 'forerunner-stack-docs', - 'forerunner-version-audit', - 'forerunner-refresh', -]; +// Task Registry — single source of truth for installable skill slugs, shared +// with the Python installer (src/codeforerunner/tasks.py). The slug list is +// loaded from this file at install time via loadTaskSkillSlugs() rather than +// duplicated here. +const TASKS_JSON_REL = path.join('src', 'codeforerunner', 'tasks.json'); // ── Argv ────────────────────────────────────────────────────────────────── @@ -332,14 +319,12 @@ async function promptGlobalOrLocal(c) { // ── Local install helpers ───────────────────────────────────────────────── -async function fetchSkill(slug, repoRoot) { - if (repoRoot) { - const p = path.join(repoRoot, 'skills', slug, 'SKILL.md'); - try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; } - } +// GET a raw text resource over HTTPS, following one level of redirects. +// Resolves to the body string, or null on any non-200/error/timeout. +function fetchRawText(url) { return new Promise((resolve) => { - const get = (url) => { - const req = https.get(url, (res) => { + const get = (u) => { + const req = https.get(u, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { get(res.headers.location); res.resume(); @@ -353,15 +338,62 @@ async function fetchSkill(slug, repoRoot) { req.on('error', () => resolve(null)); req.setTimeout(10000, () => { req.destroy(new Error('timeout')); }); }; - get(`${RAW_BASE}/skills/${slug}/SKILL.md`); + get(url); }); } +async function fetchSkill(slug, repoRoot) { + if (repoRoot) { + const p = path.join(repoRoot, 'skills', slug, 'SKILL.md'); + try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; } + } + return fetchRawText(`${RAW_BASE}/skills/${slug}/SKILL.md`); +} + +// Derive the install slug list from tasks.json, mirroring Python's +// tasks.installable_slugs(): the canonical skill slug followed by each task's +// skill_slug in registry order, deduped against the canonical slug. +function slugsFromTasksJson(text) { + const data = JSON.parse(text); + const canonical = data.canonical_skill_slug; + if (typeof canonical !== 'string' || !canonical) { + throw new Error('tasks.json missing canonical_skill_slug'); + } + const slugs = [canonical]; + for (const t of data.tasks || []) { + if (t.skill_slug && t.skill_slug !== canonical) slugs.push(t.skill_slug); + } + return slugs; +} + +// Load installable skill slugs from the Task Registry. Reads tasks.json from a +// local checkout when available (local clone or the packaged file beside this +// script), else fetches it over HTTPS — the same local-or-remote strategy +// fetchSkill() uses. Returns null if no source could be read or parsed. +async function loadTaskSkillSlugs(repoRoot) { + const localCandidates = []; + if (repoRoot) localCandidates.push(path.join(repoRoot, TASKS_JSON_REL)); + localCandidates.push(path.resolve(__dirname, '..', TASKS_JSON_REL)); + for (const p of localCandidates) { + try { return slugsFromTasksJson(fs.readFileSync(p, 'utf8')); } catch (_) { /* try next */ } + } + const remoteUrl = `${RAW_BASE}/${TASKS_JSON_REL.split(path.sep).join('/')}`; + const text = await fetchRawText(remoteUrl); + if (text) { + try { return slugsFromTasksJson(text); } catch (_) { /* fall through to null */ } + } + return null; +} + async function writeSkillsLocal(opts, results, c) { if (opts.only.length) { die('error: --only cannot be used with --local (local install writes all skills)'); } const repoRoot = detectRepoRoot(); + const taskSkillSlugs = await loadTaskSkillSlugs(repoRoot); + if (!taskSkillSlugs || !taskSkillSlugs.length) { + die('error: could not load installable skill slugs from tasks.json (Task Registry)'); + } const cwd = process.cwd(); // Claude Code natively recognises .claude/skills//SKILL.md. @@ -373,7 +405,7 @@ async function writeSkillsLocal(opts, results, c) { process.stdout.write(c.dim(` target: ${cwd}\n`)); if (opts.dryRun) process.stdout.write(c.yellow(' (dry-run — no files written)\n')); - for (const slug of TASK_SKILL_SLUGS) { + for (const slug of taskSkillSlugs) { process.stdout.write(`\n${c.bold(`→ ${slug}`)}\n`); const content = await fetchSkill(slug, repoRoot); if (!content) { @@ -604,4 +636,9 @@ async function main() { if (results.failed.length) process.exit(1); } -main().catch(err => { process.stderr.write(err.stack + '\n'); process.exit(2); }); +if (require.main === module) { + main().catch(err => { process.stderr.write(err.stack + '\n'); process.exit(2); }); +} + +// Exported for tests (tests/install.test.js, tests/test_installer.py) — kept minimal. +module.exports = { loadTaskSkillSlugs, slugsFromTasksJson }; diff --git a/package.json b/package.json index 8e7c3bb..a941bc1 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ "files": [ "bin/", "skills/", + "src/codeforerunner/tasks.json", "install.sh", "install.ps1", "skills-lock.json" ], + "scripts": { + "test": "node --test tests/install.test.js" + }, "engines": { "node": ">=18" }, diff --git a/tests/install.test.js b/tests/install.test.js new file mode 100644 index 0000000..c103065 --- /dev/null +++ b/tests/install.test.js @@ -0,0 +1,121 @@ +'use strict'; + +// Behavior tests for the installer's Task Registry slug loading. +// Run: node --test tests/install.test.js +// +// Scope is deliberately the public, exported surface of bin/install.js +// (slugsFromTasksJson, loadTaskSkillSlugs) plus the observable CLI behavior +// when the registry cannot be read. No internal/private functions are touched, +// so these survive refactors of the install machinery. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_JS = path.join(REPO_ROOT, 'bin', 'install.js'); + +const { slugsFromTasksJson, loadTaskSkillSlugs } = require(INSTALL_JS); + +test('slugsFromTasksJson lists the canonical skill slug first', () => { + const slugs = slugsFromTasksJson(JSON.stringify({ + canonical_skill_slug: 'codeforerunner', + tasks: [{ name: 'scan', skill_slug: 'forerunner-scan' }], + })); + assert.equal(slugs[0], 'codeforerunner'); +}); + +test('slugsFromTasksJson does not repeat a task slug equal to the canonical', () => { + const slugs = slugsFromTasksJson(JSON.stringify({ + canonical_skill_slug: 'codeforerunner', + tasks: [ + { name: 'self', skill_slug: 'codeforerunner' }, + { name: 'scan', skill_slug: 'forerunner-scan' }, + ], + })); + assert.deepEqual(slugs, ['codeforerunner', 'forerunner-scan']); +}); + +test('slugsFromTasksJson skips tasks with no skill_slug', () => { + const slugs = slugsFromTasksJson(JSON.stringify({ + canonical_skill_slug: 'codeforerunner', + tasks: [ + { name: 'scan', skill_slug: 'forerunner-scan' }, + { name: 'internal', skill_slug: null }, + { name: 'also-internal' }, + ], + })); + assert.deepEqual(slugs, ['codeforerunner', 'forerunner-scan']); +}); + +test('slugsFromTasksJson preserves registry (tasks array) order', () => { + const slugs = slugsFromTasksJson(JSON.stringify({ + canonical_skill_slug: 'codeforerunner', + tasks: [ + { name: 'b', skill_slug: 'forerunner-b' }, + { name: 'a', skill_slug: 'forerunner-a' }, + { name: 'c', skill_slug: 'forerunner-c' }, + ], + })); + assert.deepEqual(slugs, ['codeforerunner', 'forerunner-b', 'forerunner-a', 'forerunner-c']); +}); + +test('slugsFromTasksJson throws on malformed JSON', () => { + assert.throws(() => slugsFromTasksJson('{ not json')); +}); + +test('slugsFromTasksJson throws when canonical_skill_slug is missing', () => { + // Mirrors Python's KeyError — a registry with no canonical slug is invalid, + // not a slug list of [undefined]. + assert.throws(() => slugsFromTasksJson(JSON.stringify({ + tasks: [{ name: 'scan', skill_slug: 'forerunner-scan' }], + }))); +}); + +test('loadTaskSkillSlugs reads the real tasks.json from a local checkout', async () => { + const slugs = await loadTaskSkillSlugs(REPO_ROOT); + assert.ok(Array.isArray(slugs) && slugs.length > 0, 'expected a non-empty slug list'); + assert.equal(slugs[0], 'codeforerunner'); + assert.ok(slugs.includes('forerunner-scan')); +}); + +test('local install exits non-zero with a clear error when the registry is unreadable', () => { + // Preload stub: poison every tasks.json read path (local file + HTTPS) so + // loadTaskSkillSlugs() resolves null, exercising writeSkillsLocal's guard. + // readFileSync throws (local candidates fail); https.get errors (remote fails). + const stub = path.join(os.tmpdir(), `cfr-stub-${process.pid}-${Date.now()}.js`); + fs.writeFileSync(stub, ` + const fs = require('fs'); + // Poison only tasks.json reads — Node's module loader uses readFileSync to + // load install.js itself, so a blanket stub would crash before our guard runs. + const realReadFileSync = fs.readFileSync; + fs.readFileSync = function (p, ...rest) { + if (typeof p === 'string' && p.includes('tasks.json')) throw new Error('blocked by test stub'); + return realReadFileSync.call(this, p, ...rest); + }; + const https = require('https'); + https.get = function () { + const req = { + on(ev, cb) { if (ev === 'error') process.nextTick(() => cb(new Error('blocked'))); return req; }, + setTimeout() { return req; }, + destroy() {}, + }; + return req; + }; + `, 'utf8'); + + try { + const res = spawnSync( + process.execPath, + ['--require', stub, INSTALL_JS, '--local', '--non-interactive', '--no-color'], + { encoding: 'utf8', cwd: os.tmpdir() }, + ); + assert.notEqual(res.status, 0, 'expected a non-zero exit'); + assert.match((res.stderr || '') + (res.stdout || ''), /tasks\.json/); + } finally { + fs.rmSync(stub, { force: true }); + } +}); diff --git a/tests/test_installer.py b/tests/test_installer.py index 37ab943..1420a85 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -290,6 +290,39 @@ def test_task_skill_slugs_include_arch_review(): assert (REPO / "skills/forerunner-arch-review/SKILL.md").is_file() +def test_node_installer_slug_list_matches_registry(): + """bin/install.js must derive the same slug list as tasks.installable_slugs(). + + The Node installer reads tasks.json at install time (no hardcoded list), so + this guards against the two installers drifting apart. + """ + import shutil + import subprocess + + from codeforerunner.tasks import installable_slugs + + node = shutil.which("node") + if node is None: + pytest.skip("node not available") + + install_js = REPO / "bin" / "install.js" + script = ( + "require(process.argv[1])" + ".loadTaskSkillSlugs(process.argv[2])" + ".then(s => process.stdout.write(JSON.stringify(s)))" + ".catch(e => { process.stderr.write(String(e)); process.exit(1); });" + ) + proc = subprocess.run( + [node, "-e", script, str(install_js), str(REPO)], + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + node_slugs = json.loads(proc.stdout) + assert node_slugs == list(installable_slugs()) + + # ── install_all_skills ──────────────────────────────────────────────────────── def test_install_all_skills_check_only(tmp_path):