#!/usr/bin/env node /** * scripts/onboard.mjs — bootstrap a target repo onto redsen-lean-harness. * * For local/dev use before the plugin is published to a marketplace: copies * the behaviour layer (.github/agents, skills, instructions, prompts, * mcp.json, copilot-instructions.md, AGENTS.md) into a target repo, links the * `lh` CLI, runs `lh init` + `lh doctor`, and prints next steps. Never * overwrites a file that already differs unless --force is passed. * * Usage: * node scripts/onboard.mjs [--yes] [--force] [--dry-run] [--no-npm-link] */ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const HARNESS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const CLI = path.join(HARNESS_ROOT, 'src', 'cli.mjs'); const PKG_NAME = JSON.parse(fs.readFileSync(path.join(HARNESS_ROOT, 'package.json'), 'utf8')).name; const NO_COLOR = process.env.NO_COLOR !== undefined || !process.stdout.isTTY; const wrap = (code, s) => (NO_COLOR ? s : `\u001b[${code}m${s}\u001b[0m`); const dim = (s) => wrap('2', s); const bold = (s) => wrap('1', s); const red = (s) => wrap('31', s); const green = (s) => wrap('32', s); const yellow = (s) => wrap('33', s); const out = (l = '') => process.stdout.write(`${l}\n`); const err = (l) => process.stderr.write(`${l}\n`); const ok = (m) => out(`${green('ok')} ${m}`); const warn = (m) => out(`${yellow('warn')} ${m}`); const step = (m) => out(`\n${bold(m)}`); function parseArgs(argv) { const flags = {}; const positional = []; for (const arg of argv) { if (arg.startsWith('--')) flags[arg.slice(2)] = true; else positional.push(arg); } return { flags, positional }; } function usage() { out(`usage: node scripts/onboard.mjs [--yes] [--force] [--dry-run] [--no-npm-link] repo to onboard onto the harness (must already exist) --yes non-interactive: run \`git init\` (if needed) and \`lh init\` without asking --force overwrite target files that already exist and differ --dry-run print what would happen, write and run nothing --no-npm-link skip \`npm link\`; invoke lh via its absolute path instead`); } // Behaviour-layer paths copied verbatim into the target repo. Everything else // (src/, templates/, tests/, workflows) stays inside this package — the `lh` // CLI resolves templates relative to its own install location, not the // target repo, so nothing else needs to travel. const HARNESS_FILES = [ '.github/agents', '.github/skills', '.github/instructions', '.github/prompts', '.github/mcp.json', '.github/copilot-instructions.md', 'AGENTS.md', ]; function listFilesRecursive(entry) { if (!fs.existsSync(entry)) return []; if (!fs.statSync(entry).isDirectory()) return [entry]; const found = []; for (const child of fs.readdirSync(entry, { withFileTypes: true })) { const full = path.join(entry, child.name); if (child.isDirectory()) found.push(...listFilesRecursive(full)); else found.push(full); } return found; } function planCopy(target) { const plan = []; for (const rel of HARNESS_FILES) { for (const src of listFilesRecursive(path.join(HARNESS_ROOT, rel))) { const dest = path.join(target, path.relative(HARNESS_ROOT, src)); const exists = fs.existsSync(dest); const identical = exists && fs.readFileSync(src).equals(fs.readFileSync(dest)); plan.push({ src, dest, exists, identical }); } } return plan; } function applyCopy(plan, { force, dryRun }) { const written = []; const conflicts = []; for (const item of plan) { if (item.exists && item.identical) continue; if (item.exists && !item.identical && !force) { conflicts.push(item.dest); continue; } if (!dryRun) { fs.mkdirSync(path.dirname(item.dest), { recursive: true }); fs.copyFileSync(item.src, item.dest); } written.push(item.dest); } return { written, conflicts }; } function run(cmd, args, opts = {}) { try { const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }); return { code: 0, stdout, stderr: '' }; } catch (e) { return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message ?? e) }; } } const nodeMajor = () => Number(process.versions.node.split('.')[0]); function main() { const { flags, positional } = parseArgs(process.argv.slice(2)); if (!positional[0] || flags.help || flags.h) { usage(); process.exit(positional[0] ? 0 : 2); } const target = path.resolve(process.cwd(), positional[0]); const dryRun = Boolean(flags['dry-run']); const forceWrite = Boolean(flags.force); const yes = Boolean(flags.yes) || dryRun; // dry-run never mutates, so treat as answered const noLink = Boolean(flags['no-npm-link']); let failed = false; out(bold('redsen-lean-harness onboarding')); out(dim(`harness: ${HARNESS_ROOT}`)); out(dim(`target: ${target}`)); if (dryRun) out(dim('(dry run — no files written, no commands executed)')); // ---- 1. prerequisites -------------------------------------------------- step('1. prerequisites'); if (nodeMajor() < 20) { err(`${red('fail')} node ${process.version} — need >= 20`); failed = true; } else ok(`node ${process.version}`); const gitVersion = run('git', ['--version']); if (gitVersion.code !== 0) { err(`${red('fail')} git not found on PATH`); failed = true; } else ok(gitVersion.stdout.trim()); if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { err(`${red('fail')} target directory does not exist: ${target}`); failed = true; } else ok('target directory exists'); if (failed) { err('\naborting — fix the above and re-run.'); process.exit(1); } const gitCheck = run('git', ['rev-parse', '--show-toplevel'], { cwd: target }); const isGitRepo = gitCheck.code === 0; if (isGitRepo) { ok(`git repo (root: ${gitCheck.stdout.trim()})`); } else if (yes) { warn('not a git repo — running `git init`'); if (!dryRun) run('git', ['init', '-q'], { cwd: target }); } else { warn('not a git repo — pass --yes to auto-`git init`, or run it yourself first'); } // ---- 2. copy behaviour layer ------------------------------------------- step('2. copy behaviour layer (.github + AGENTS.md)'); const plan = planCopy(target); const { written, conflicts } = applyCopy(plan, { force: forceWrite, dryRun }); if (written.length) { for (const f of written) out(` ${dryRun ? 'would write' : 'wrote'} ${path.relative(target, f)}`); ok(`${written.length} file(s) ${dryRun ? 'would be written' : 'written'}`); } else { ok('nothing to write — already up to date'); } if (conflicts.length) { warn(`${conflicts.length} file(s) already exist and differ — skipped (rerun with --force to overwrite):`); for (const f of conflicts) out(` ${path.relative(target, f)}`); } // ---- 3. link the lh CLI ------------------------------------------------- step('3. link the `lh` CLI'); let lhCommand = ['node', CLI]; if (dryRun) { out(dim('(dry run — skipping npm link)')); } else if (noLink) { out(dim('--no-npm-link — using absolute path invocation')); } else { const linkSelf = run('npm', ['link'], { cwd: HARNESS_ROOT }); const linkTarget = linkSelf.code === 0 ? run('npm', ['link', PKG_NAME], { cwd: target }) : linkSelf; if (linkSelf.code === 0 && linkTarget.code === 0) { ok('linked — `lh` is now on PATH inside the target repo (via node_modules/.bin)'); lhCommand = ['lh']; } else { warn('`npm link` failed — falling back to absolute path invocation'); out(dim((linkTarget.stderr || linkSelf.stderr).trim().split('\n')[0] ?? '')); } } const lh = (args) => run(lhCommand[0], [...lhCommand.slice(1), ...args], { cwd: target }); // ---- 4. lh init --------------------------------------------------------- step('4. `lh init`'); if (dryRun) { out(dim('(dry run — skipping `lh init`)')); } else if (!yes) { warn('skipping `lh init` — pass --yes to run it non-interactively'); } else { const init = lh(['init', '--yes']); if (init.code === 0) { ok(init.stdout.trim() || 'lh init ok'); } else if (/config exists/i.test(init.stderr)) { warn('already initialized — rerun with `lh init --force` to reset .agents/harness.config.json'); } else { err(`${red('fail')} lh init exited ${init.code}`); out(init.stderr.trim()); failed = true; } } // ---- 5. lh doctor -------------------------------------------------------- step('5. `lh doctor`'); if (dryRun) { out(dim('(dry run — skipping `lh doctor`)')); } else { const doctor = lh(['doctor']); out(doctor.stdout.trim()); if (doctor.code !== 0) warn('doctor reported missing requirements — see above'); else ok('doctor: all required checks passed'); } // ---- summary ------------------------------------------------------------ step('done'); const lhHint = lhCommand[0] === 'lh' ? 'lh' : `node ${path.relative(target, CLI)}`; out(` cd ${target} ${lhHint} doctor ${lhHint} report # after your first run In Copilot CLI or VS Code Copilot: @conductor /fast-track /onboard # summarize repo state for a new contributor Full pipeline docs: ${path.join(HARNESS_ROOT, 'README.md')}`); process.exit(failed ? 1 : 0); } main();