diff --git a/package.json b/package.json index e2af027..a7f8804 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check \"src/**/*.ts\"", "prepare": "husky", "setup": "tsx setup/index.ts", + "setup:auto": "tsx setup/auto.ts", "chat": "tsx scripts/chat.ts", "auth": "tsx src/whatsapp-auth.ts", "lint": "eslint src/", diff --git a/setup/auto.ts b/setup/auto.ts new file mode 100644 index 0000000..0cbac93 --- /dev/null +++ b/setup/auto.ts @@ -0,0 +1,164 @@ +/** + * Non-interactive setup driver. Chains the deterministic setup steps so a + * scripted install can go from a fresh checkout to a running service without + * the `/setup` skill. + * + * Prerequisite: `bash setup.sh` has run (Node >= 20, pnpm install, native + * module check). This driver picks up from there. + * + * Config via env: + * NANOCLAW_TZ IANA zone override (skip autodetect) + * NANOCLAW_SKIP comma-separated step names to skip + * (environment|timezone|container|mounts|service|verify) + * + * Credential setup (OneCLI + channel auth + `/manage-channels`) is *not* + * scripted — those require interactive platform flows and are handled by + * `/setup`, `/add-`, and `/manage-channels` afterwards. + */ +import { spawn } from 'child_process'; + +type Fields = Record; +type StepResult = { ok: boolean; fields: Fields; exitCode: number }; + +function parseStatus(stdout: string): Fields { + const out: Fields = {}; + let inBlock = false; + for (const line of stdout.split('\n')) { + if (line.startsWith('=== NANOCLAW SETUP:')) { + inBlock = true; + continue; + } + if (line.startsWith('=== END ===')) { + inBlock = false; + continue; + } + if (!inBlock) continue; + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +function runStep(name: string, extra: string[] = []): Promise { + return new Promise((resolve) => { + console.log(`\n── ${name} ────────────────────────────────────`); + const args = ['exec', 'tsx', 'setup/index.ts', '--step', name]; + if (extra.length > 0) args.push('--', ...extra); + + const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); + let buf = ''; + child.stdout.on('data', (chunk: Buffer) => { + const s = chunk.toString('utf-8'); + buf += s; + process.stdout.write(s); + }); + child.on('close', (code) => { + const fields = parseStatus(buf); + resolve({ + ok: code === 0 && fields.STATUS === 'success', + fields, + exitCode: code ?? 1, + }); + }); + }); +} + +function fail(msg: string, hint?: string): never { + console.error(`\n[setup:auto] ${msg}`); + if (hint) console.error(` ${hint}`); + console.error(' Logs: logs/setup.log'); + process.exit(1); +} + +async function main(): Promise { + const skip = new Set( + (process.env.NANOCLAW_SKIP ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + const tz = process.env.NANOCLAW_TZ; + + if (!skip.has('environment')) { + const env = await runStep('environment'); + if (!env.ok) fail('environment check failed'); + } + + if (!skip.has('timezone')) { + const res = await runStep('timezone', tz ? ['--tz', tz] : []); + if (res.fields.NEEDS_USER_INPUT === 'true') { + fail( + 'Timezone could not be autodetected.', + 'Set NANOCLAW_TZ to an IANA zone (e.g. NANOCLAW_TZ=America/New_York).', + ); + } + if (!res.ok) fail('timezone step failed'); + } + + if (!skip.has('container')) { + const res = await runStep('container'); + if (!res.ok) { + if (res.fields.ERROR === 'runtime_not_available') { + fail( + 'Docker is not available and could not be started automatically.', + 'Install Docker Desktop or start it manually, then retry.', + ); + } + fail( + 'container build/test failed', + 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + ); + } + } + + if (!skip.has('mounts')) { + const res = await runStep('mounts', ['--empty']); + if (!res.ok && res.fields.STATUS !== 'skipped') { + fail('mount allowlist step failed'); + } + } + + if (!skip.has('service')) { + const res = await runStep('service'); + if (!res.ok) { + fail( + 'service install failed', + 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', + ); + } + if (res.fields.DOCKER_GROUP_STALE === 'true') { + console.warn( + '\n[setup:auto] Docker group stale in systemd session. Run:\n' + + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + + ' systemctl --user restart nanoclaw', + ); + } + } + + if (!skip.has('verify')) { + const res = await runStep('verify'); + if (!res.ok) { + console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); + if (res.fields.CREDENTIALS !== 'configured') { + console.log(' • OneCLI + Anthropic secret — see `/setup` §4 or https://onecli.sh'); + } + if (!res.fields.CONFIGURED_CHANNELS) { + console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); + } + if (res.fields.REGISTERED_GROUPS === '0') { + console.log(' • Wire the channel to an agent group: `/manage-channels`'); + } + return; + } + } + + console.log('\n[setup:auto] Complete.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/setup/container.ts b/setup/container.ts index 3e48ecf..aadd04c 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -4,11 +4,54 @@ */ import { execSync } from 'child_process'; import path from 'path'; +import { setTimeout as sleep } from 'timers/promises'; import { log } from '../src/log.js'; -import { commandExists } from './platform.js'; +import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; +function dockerRunning(): boolean { + try { + execSync('docker info', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Try to start Docker if it's installed but idle. Poll for up to 60s. + * Returns true once `docker info` succeeds, false if we gave up. + */ +async function tryStartDocker(): Promise { + const platform = getPlatform(); + log.info('Docker not running — attempting to start', { platform }); + + try { + if (platform === 'macos') { + execSync('open -a Docker', { stdio: 'ignore' }); + } else if (platform === 'linux') { + // Inherit stdio so sudo can prompt for a password if needed. + execSync('sudo systemctl start docker', { stdio: 'inherit' }); + } else { + return false; + } + } catch (err) { + log.warn('Start command failed', { err }); + return false; + } + + for (let i = 0; i < 30; i++) { + await sleep(2000); + if (dockerRunning()) { + log.info('Docker is up'); + return true; + } + } + log.warn('Docker did not become ready within 60s'); + return false; +} + function parseArgs(args: string[]): { runtime: string } { // `--runtime` is still accepted for backwards compatibility with the /setup // skill, but `docker` is the only supported value. @@ -54,19 +97,20 @@ export async function run(args: string[]): Promise { process.exit(2); } - try { - execSync('docker info', { stdio: 'ignore' }); - } catch { - emitStatus('SETUP_CONTAINER', { - RUNTIME: runtime, - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'runtime_not_available', - LOG: 'logs/setup.log', - }); - process.exit(2); + if (!dockerRunning()) { + const started = await tryStartDocker(); + if (!started) { + emitStatus('SETUP_CONTAINER', { + RUNTIME: runtime, + IMAGE: image, + BUILD_OK: false, + TEST_OK: false, + STATUS: 'failed', + ERROR: 'runtime_not_available', + LOG: 'logs/setup.log', + }); + process.exit(2); + } } const buildCmd = 'docker build';