feat: onboarding script, full lh flag reference, expanded README

- 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>
This commit is contained in:
2026-09-09 22:58:46 +02:00
co-authored by Copilot
parent cd36cc0efc
commit da20a6eaff
4 changed files with 409 additions and 2 deletions
+262
View File
@@ -0,0 +1,262 @@
#!/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();