- scripts/onboard.mjs: bootstraps a target repo onto the harness — copies the behaviour layer (.github/agents, skills, instructions, prompts, mcp.json, copilot-instructions.md, AGENTS.md), npm links the `lh` CLI, runs `lh init` + `lh doctor`, prints next steps. Idempotent: identical files are skipped, differing files require --force, re-running `lh init` on an initialized repo warns instead of failing. Supports --dry-run and --no-npm-link for CI/sandboxed use. - tests/onboard.test.mjs: 5 new e2e tests (dry-run, full run, idempotent rerun, missing target dir, conflict + --force). - README: new "Onboarding a new project" section, full lh flag reference for every subcommand (previously only one-line summaries), Development section mentions the onboarding script. Live-tested against ~/Sources/ralph-runtime (a real, not-yet-git-tracked project): git init, .github/ copied, `npm link` succeeded, `lh init` ran, `lh doctor` correctly flagged its one real gap (no verify command configured) rather than a false pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
263 lines
9.4 KiB
JavaScript
263 lines
9.4 KiB
JavaScript
#!/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 <target-dir> [--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 <target-dir> [--yes] [--force] [--dry-run] [--no-npm-link]
|
|
|
|
<target-dir> 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 <describe what you want built>
|
|
/fast-track <small brownfield fix>
|
|
/onboard # summarize repo state for a new contributor
|
|
|
|
Full pipeline docs: ${path.join(HARNESS_ROOT, 'README.md')}`);
|
|
|
|
process.exit(failed ? 1 : 0);
|
|
}
|
|
|
|
main();
|