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:
@@ -0,0 +1,79 @@
|
||||
import { test, describe, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { tempRepo, cleanup } from './helpers.mjs';
|
||||
|
||||
after(cleanup);
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ONBOARD = join(ROOT, 'scripts', 'onboard.mjs');
|
||||
|
||||
/** Run the onboarding script directly. Never throws — returns {code, stdout, stderr}. */
|
||||
function onboard(args) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [ONBOARD, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
describe('onboard script', () => {
|
||||
test('--dry-run writes nothing', () => {
|
||||
const dir = tempRepo();
|
||||
const r = onboard([dir, '--dry-run']);
|
||||
assert.equal(r.code, 0);
|
||||
assert.ok(!existsSync(join(dir, '.github', 'agents')), 'dry run must not create files');
|
||||
assert.ok(!existsSync(join(dir, '.agents')), 'dry run must not run lh init');
|
||||
assert.match(r.stdout, /would write/);
|
||||
});
|
||||
|
||||
test('--yes --no-npm-link copies the behaviour layer and runs lh init + doctor', () => {
|
||||
const dir = tempRepo({
|
||||
'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'true' } }, null, 2),
|
||||
});
|
||||
const r = onboard([dir, '--yes', '--no-npm-link']);
|
||||
assert.equal(r.code, 0, r.stdout + r.stderr);
|
||||
for (const rel of ['.github/agents/conductor.agent.md', '.github/skills/design/SKILL.md',
|
||||
'.github/mcp.json', 'AGENTS.md', '.agents/harness.config.json']) {
|
||||
assert.ok(existsSync(join(dir, rel)), `expected ${rel} to exist`);
|
||||
}
|
||||
assert.match(r.stdout, /doctor: all required checks passed/);
|
||||
});
|
||||
|
||||
test('rerunning with --yes is idempotent (no forced overwrite, no init failure)', () => {
|
||||
const dir = tempRepo({
|
||||
'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'true' } }, null, 2),
|
||||
});
|
||||
assert.equal(onboard([dir, '--yes', '--no-npm-link']).code, 0);
|
||||
const second = onboard([dir, '--yes', '--no-npm-link']);
|
||||
assert.equal(second.code, 0, second.stdout + second.stderr);
|
||||
assert.match(second.stdout, /already up to date/);
|
||||
assert.match(second.stdout, /already initialized/);
|
||||
});
|
||||
|
||||
test('refuses a target directory that does not exist', () => {
|
||||
const r = onboard(['/nonexistent/path/for/sure', '--yes']);
|
||||
assert.notEqual(r.code, 0);
|
||||
});
|
||||
|
||||
test('a conflicting existing file is skipped unless --force', () => {
|
||||
const dir = tempRepo({ 'AGENTS.md': 'custom project notes, do not clobber\n' });
|
||||
const r = onboard([dir, '--yes', '--no-npm-link']);
|
||||
assert.equal(r.code, 0);
|
||||
assert.match(r.stdout, /skipped/);
|
||||
const kept = existsSync(join(dir, 'AGENTS.md'));
|
||||
assert.ok(kept, 'AGENTS.md must still exist');
|
||||
|
||||
const forced = onboard([dir, '--yes', '--no-npm-link', '--force']);
|
||||
assert.equal(forced.code, 0);
|
||||
assert.match(forced.stdout, /written/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user