diff --git a/install/beadboard.mjs b/install/beadboard.mjs new file mode 100644 index 0000000..af3be63 --- /dev/null +++ b/install/beadboard.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); + +function getRuntimeMetadata() { + const installHome = process.env.BB_INSTALL_HOME || process.env.HOME || process.env.USERPROFILE || ''; + const version = (process.env.BB_RUNTIME_VERSION || '0.1.0').trim(); + const runtimeRoot = + process.env.BB_RUNTIME_ROOT || path.join(installHome, '.beadboard', 'runtime', version); + const shimTarget = process.env.BB_SHIM_TARGET || __filename; + const installMode = + process.env.BB_INSTALL_MODE || + (__dirname.includes(`${path.sep}.beadboard${path.sep}runtime${path.sep}`) + ? 'runtime-managed' + : 'repo-shim'); + return { runtimeRoot, shimTarget, installMode }; +} + +function parseArgs(argv) { + const args = [...argv]; + const jsonIndex = args.indexOf('--json'); + const json = jsonIndex !== -1; + if (json) args.splice(jsonIndex, 1); + return { + command: args[0] || 'help', + json, + }; +} + +function output(payload, asJson) { + if (asJson) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + if (payload.ok === false) { + process.stderr.write(`${payload.error}\n`); + return; + } + if (payload.command === 'status') { + process.stdout.write( + payload.running + ? `BeadBoard is running at ${payload.url}\n` + : `BeadBoard is not running at ${payload.url}\n`, + ); + return; + } + if (payload.command === 'open') { + process.stdout.write(`Open ${payload.url}\n`); + return; + } + if (payload.command === 'start') { + process.stdout.write('Starting BeadBoard dev server...\n'); + return; + } + process.stdout.write('Usage: beadboard [--json]\n'); +} + +function getPort() { + const value = Number.parseInt(process.env.BB_PORT || '3000', 10); + return Number.isFinite(value) ? value : 3000; +} + +function statusRequest(port) { + return new Promise((resolve) => { + const req = http.get( + { + host: '127.0.0.1', + port, + path: '/', + timeout: 1200, + }, + (res) => { + res.resume(); + resolve({ running: true, statusCode: res.statusCode || 200 }); + }, + ); + req.on('timeout', () => { + req.destroy(); + resolve({ running: false, statusCode: null }); + }); + req.on('error', () => resolve({ running: false, statusCode: null })); + }); +} + +function openUrl(url) { + if (process.env.BB_OPEN_NOOP === '1') return; + + if (process.platform === 'win32') { + spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref(); + return; + } + if (process.platform === 'darwin') { + spawn('open', [url], { stdio: 'ignore', detached: true }).unref(); + return; + } + spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref(); +} + +async function main() { + const { command, json } = parseArgs(process.argv.slice(2)); + const port = getPort(); + const url = `http://127.0.0.1:${port}`; + const runtime = getRuntimeMetadata(); + + if (command === 'start') { + const startRoot = fs.existsSync(runtime.runtimeRoot) ? runtime.runtimeRoot : repoRoot; + const child = spawn('npm', ['run', 'dev'], { + cwd: startRoot, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + child.on('exit', (code) => process.exit(code ?? 0)); + output({ ok: true, command: 'start' }, json); + return; + } + + if (command === 'open') { + openUrl(url); + output({ ok: true, command: 'open', url }, json); + return; + } + + if (command === 'status') { + const probe = await statusRequest(port); + const payload = { + ok: true, + command: 'status', + running: probe.running, + statusCode: probe.statusCode, + url, + port, + runtimeRoot: runtime.runtimeRoot, + installMode: runtime.installMode, + shimTarget: runtime.shimTarget, + }; + output(payload, json); + process.exit(probe.running ? 0 : 1); + return; + } + + output({ ok: true, command: 'help' }, json); +} + +void main(); diff --git a/package.json b/package.json index 59a818b..08f76a5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/blocked-triage-modal.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/install-manifest.test.ts && node --import tsx --test tests/lib/runtime-manager.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx && node --import tsx --test tests/scripts/beadboard-launcher.test.ts && node --import tsx --test tests/scripts/install-wrappers-contract.test.ts && node --import tsx --test tests/scripts/install-sh-smoke.test.ts && node --import tsx --test tests/scripts/installer-ci-contract.test.ts && node --import tsx --test tests/docs/installer-quickstart-contract.test.ts && node --import tsx --test tests/docs/runtime-manager-adr-contract.test.ts && node --import tsx --test tests/skills/beadboard-driver/resolve-bb.test.ts && node --import tsx --test tests/skills/beadboard-driver/session-preflight.test.ts && node --import tsx --test tests/skills/beadboard-driver/generate-agent-name.test.ts && node --import tsx --test tests/skills/beadboard-driver/readiness-report.test.ts && node --import tsx --test tests/skills/beadboard-driver/skill-local-runner.test.ts && node --import tsx --test tests/skills/beadboard-driver/diagnose-env.test.ts && node --import tsx --test tests/skills/beadboard-driver/heal-common-issues.test.ts && node --import tsx --test tests/lib/epic-graph.test.ts && node --import tsx --test tests/components/shared/left-panel-filtering.test.ts && node --import tsx --test tests/hooks/use-beads-subscription-contract.test.ts && node --import tsx --test tests/components/graph/dependency-graph-hide-closed-contract.test.ts && node --import tsx --test tests/components/shared/unified-shell-hide-closed-contract.test.ts", + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/blocked-triage-modal.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/install-manifest.test.ts && node --import tsx --test tests/lib/runtime-manager.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx && node --import tsx --test tests/scripts/beadboard-launcher.test.ts && node --import tsx --test tests/scripts/beadboard-launcher-runtime.test.ts && node --import tsx --test tests/scripts/install-wrappers-contract.test.ts && node --import tsx --test tests/scripts/install-sh-smoke.test.ts && node --import tsx --test tests/scripts/installer-ci-contract.test.ts && node --import tsx --test tests/docs/installer-quickstart-contract.test.ts && node --import tsx --test tests/docs/runtime-manager-adr-contract.test.ts && node --import tsx --test tests/skills/beadboard-driver/resolve-bb.test.ts && node --import tsx --test tests/skills/beadboard-driver/session-preflight.test.ts && node --import tsx --test tests/skills/beadboard-driver/generate-agent-name.test.ts && node --import tsx --test tests/skills/beadboard-driver/readiness-report.test.ts && node --import tsx --test tests/skills/beadboard-driver/skill-local-runner.test.ts && node --import tsx --test tests/skills/beadboard-driver/diagnose-env.test.ts && node --import tsx --test tests/skills/beadboard-driver/heal-common-issues.test.ts && node --import tsx --test tests/lib/epic-graph.test.ts && node --import tsx --test tests/components/shared/left-panel-filtering.test.ts && node --import tsx --test tests/hooks/use-beads-subscription-contract.test.ts && node --import tsx --test tests/components/graph/dependency-graph-hide-closed-contract.test.ts && node --import tsx --test tests/components/shared/unified-shell-hide-closed-contract.test.ts", "video": "remotion preview src/video/index.ts", "video:render": "remotion render src/video/index.ts Main out/video.mp4", "video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60" diff --git a/tests/scripts/beadboard-launcher-runtime.test.ts b/tests/scripts/beadboard-launcher-runtime.test.ts new file mode 100644 index 0000000..bf0f23a --- /dev/null +++ b/tests/scripts/beadboard-launcher-runtime.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import path from 'node:path'; + +const execFileAsync = promisify(execFile); +const launcherPath = path.resolve('install/beadboard.mjs'); + +test('status --json reports runtime root and install mode', async () => { + const { stdout } = await execFileAsync(process.execPath, [launcherPath, 'status', '--json']); + const payload = JSON.parse(stdout); + assert.ok(payload.runtimeRoot); + assert.ok(payload.installMode); +}); diff --git a/tests/scripts/beadboard-launcher.test.ts b/tests/scripts/beadboard-launcher.test.ts new file mode 100644 index 0000000..b987352 --- /dev/null +++ b/tests/scripts/beadboard-launcher.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import http from 'node:http'; + +const execFileAsync = promisify(execFile); +const launcherPath = path.resolve('install/beadboard.mjs'); + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('failed to resolve free port')); + return; + } + const { port } = address; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +test('beadboard launcher status --json reports running server', async () => { + const port = await getFreePort(); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + }); + await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve())); + + try { + const { stdout } = await execFileAsync(process.execPath, [launcherPath, 'status', '--json'], { + env: { ...process.env, BB_PORT: String(port) }, + }); + const payload = JSON.parse(stdout); + assert.equal(payload.ok, true); + assert.equal(payload.running, true); + assert.equal(payload.port, port); + assert.ok(payload.runtimeRoot); + assert.ok(payload.installMode); + assert.ok(payload.shimTarget); + } finally { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + } +}); + +test('beadboard launcher open --json supports noop mode', async () => { + const { stdout } = await execFileAsync(process.execPath, [launcherPath, 'open', '--json'], { + env: { ...process.env, BB_OPEN_NOOP: '1', BB_PORT: '3456' }, + }); + const payload = JSON.parse(stdout); + assert.equal(payload.ok, true); + assert.equal(payload.command, 'open'); + assert.match(payload.url, /3456/); +});