diff --git a/setup/auto.ts b/setup/auto.ts index c16b6e5..bb23650 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -1,621 +1,37 @@ /** - * 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. + * Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`. * - * Prerequisite: `bash setup.sh` has run (Node >= 20, pnpm install, native - * module check). This driver picks up from there. + * Responsibility: orchestrate the sequence of steps end-to-end and route + * between them. The runner, spawning, status parsing, spinner, abort, and + * prompt primitives live in `setup/lib/runner.ts`; theming in + * `setup/lib/theme.ts`; Telegram's full flow in `setup/channels/telegram.ts`. * * Config via env: * NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the * prompt. Defaults to $USER. - * NANOCLAW_AGENT_NAME name for the messaging-channel agent (Telegram, - * etc.) — skips the prompt. Defaults to "Nano". - * (The CLI scratch agent is always "Terminal Agent".) + * NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the + * channel flow). The CLI scratch agent is always + * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| * service|cli-agent|channel|verify) * - * Timezone is not configured here — it defaults to the host system's TZ. - * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later - * if autodetect is wrong (e.g. headless server with TZ=UTC). - * - * UI is rendered with @clack/prompts: spinners wrap each step, child output - * is captured quietly and only dumped on failure. Interactive children - * (register-claude-token.sh, add-telegram.sh) bypass the spinner and run - * with inherited stdio — clack resumes cleanly on the next step. + * Timezone defaults to the host system's TZ. Run + * pnpm exec tsx setup/index.ts --step timezone -- --tz + * later if autodetect is wrong. */ import { spawn, spawnSync } from 'child_process'; -import fs from 'fs'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; +import { ensureAnswer, fail, runQuietStep } from './lib/runner.js'; +import { brandBold, brandChip } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; -const DEFAULT_AGENT_NAME = 'Nano'; - const RUN_START = Date.now(); -let failingStep = 'setup'; - -/** - * Brand palette, pulled from assets/nanoclaw-logo.png: - * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body - * brand navy ≈ #171B3B — the dark logo background + outlines - * Gated on TTY + NO_COLOR so piped / CI output stays plain. Falls back to - * kleur's 16-color cyan when the terminal isn't truecolor. - */ -const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; -const TRUECOLOR = - USE_ANSI && - (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit'); - -const brand = (s: string): string => { - if (!USE_ANSI) return s; - if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`; - return k.cyan(s); -}; -const brandBold = (s: string): string => { - if (!USE_ANSI) return s; - if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`; - return k.bold(k.cyan(s)); -}; -const brandChip = (s: string): string => { - if (!USE_ANSI) return s; - if (TRUECOLOR) { - return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`; - } - return k.bgCyan(k.black(k.bold(s))); -}; - -type Fields = Record; -type Block = { type: string; fields: Fields }; -type StepResult = { - ok: boolean; - exitCode: number; - blocks: Block[]; - transcript: string; - /** The last block matching `stepName.toUpperCase()` if any. */ - terminal: Block | null; -}; - -/** - * Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each - * block as it closes so the UI can react mid-stream (e.g. render a pairing - * code card as soon as pair-telegram emits it, rather than after the step - * has finished). - */ -class StatusStream { - private lineBuf = ''; - private current: Block | null = null; - readonly blocks: Block[] = []; - transcript = ''; - - constructor(private readonly onBlock: (block: Block) => void) {} - - write(chunk: string): void { - this.transcript += chunk; - this.lineBuf += chunk; - let idx: number; - while ((idx = this.lineBuf.indexOf('\n')) !== -1) { - const line = this.lineBuf.slice(0, idx); - this.lineBuf = this.lineBuf.slice(idx + 1); - this.processLine(line); - } - } - - private processLine(line: string): void { - const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/); - if (start) { - this.current = { type: start[1], fields: {} }; - return; - } - if (line.startsWith('=== END ===')) { - if (this.current) { - this.blocks.push(this.current); - this.onBlock(this.current); - this.current = null; - } - return; - } - if (!this.current) return; - const colon = line.indexOf(':'); - if (colon === -1) return; - const key = line.slice(0, colon).trim(); - const value = line.slice(colon + 1).trim(); - if (key) this.current.fields[key] = value; - } -} - -/** - * Spawn a setup step as a child process. Output is tee'd to the provided - * raw log file (level 3) and parsed for status blocks (level 2 summary). - * The onBlock callback fires per status block as they close so the UI can - * react mid-stream. - */ -function spawnStep( - stepName: string, - extra: string[], - onBlock: (block: Block) => void, - rawLogPath: string, -): Promise { - return new Promise((resolve) => { - const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; - if (extra.length > 0) args.push('--', ...extra); - - const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - const stream = new StatusStream(onBlock); - const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); - raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); - - child.stdout.on('data', (chunk: Buffer) => { - stream.write(chunk.toString('utf-8')); - raw.write(chunk); - }); - child.stderr.on('data', (chunk: Buffer) => { - stream.transcript += chunk.toString('utf-8'); - raw.write(chunk); - }); - - child.on('close', (code) => { - raw.end(); - // Step block types don't always mirror step names (e.g. `mounts` emits - // CONFIGURE_MOUNTS, `container` emits SETUP_CONTAINER). Any block with - // a STATUS field is a terminal block; the last one wins. - const terminal = - [...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null; - const status = terminal?.fields.STATUS; - const ok = code === 0 && (status === 'success' || status === 'skipped'); - resolve({ - ok, - exitCode: code ?? 1, - blocks: stream.blocks, - transcript: stream.transcript, - terminal, - }); - }); - }); -} - -type SpinnerLabels = { - running: string; - done: string; - skipped?: string; - failed?: string; -}; - -/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ -async function runQuietStep( - stepName: string, - labels: SpinnerLabels, - extra: string[] = [], -): Promise { - failingStep = stepName; - const rawLog = setupLog.stepRawLog(stepName); - const start = Date.now(); - const result = await runUnderSpinner(labels, () => - spawnStep(stepName, extra, () => {}, rawLog), - ); - const durationMs = Date.now() - start; - writeStepEntry(stepName, result, durationMs, rawLog); - return { ...result, rawLog, durationMs }; -} - -/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ -async function runQuietChild( - logName: string, - cmd: string, - args: string[], - labels: SpinnerLabels, - opts?: { - /** Extra fields to merge into the progression entry (on top of any status-block fields). */ - extraFields?: Record; - /** Environment overrides to pass to the child process. */ - env?: NodeJS.ProcessEnv; - }, -): Promise<{ - ok: boolean; - exitCode: number; - transcript: string; - terminal: Block | null; - rawLog: string; - durationMs: number; -}> { - failingStep = logName; - const rawLog = setupLog.stepRawLog(logName); - const start = Date.now(); - const result = await runUnderSpinner(labels, () => - spawnQuiet(cmd, args, rawLog, opts?.env), - ); - const durationMs = Date.now() - start; - - const blockFields = summariseTerminalFields(result.terminal); - const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; - const rawStatus = result.terminal?.fields.STATUS; - const status: 'success' | 'skipped' | 'failed' = !result.ok - ? 'failed' - : rawStatus === 'skipped' - ? 'skipped' - : 'success'; - setupLog.step(logName, status, durationMs, fields, rawLog); - return { ...result, rawLog, durationMs }; -} - -/** Turn a step's terminal-block fields into a concise progression-log entry. */ -function writeStepEntry( - stepName: string, - result: StepResult, - durationMs: number, - rawLog: string, -): void { - const rawStatus = result.terminal?.fields.STATUS; - const logStatus: 'success' | 'skipped' | 'failed' = !result.ok - ? 'failed' - : rawStatus === 'skipped' - ? 'skipped' - : 'success'; - const fields = summariseTerminalFields(result.terminal); - setupLog.step(stepName, logStatus, durationMs, fields, rawLog); -} - -/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ -function summariseTerminalFields(block: Block | null): Record { - if (!block) return {}; - const out: Record = {}; - for (const [k, v] of Object.entries(block.fields)) { - if (k === 'STATUS' || k === 'LOG') continue; - if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log - out[k] = v; - } - return out; -} - -async function runUnderSpinner< - T extends { ok: boolean; transcript: string; terminal?: Block | null }, ->( - labels: SpinnerLabels, - work: () => Promise, -): Promise { - const s = p.spinner(); - const start = Date.now(); - s.start(labels.running); - const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`); - }, 1000); - - const result = await work(); - - clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - if (result.ok) { - const isSkipped = result.terminal?.fields.STATUS === 'skipped'; - const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; - s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`); - } else { - const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); - s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1); - dumpTranscriptOnFailure(result.transcript); - } - return result; -} - -function spawnQuiet( - cmd: string, - args: string[], - rawLogPath: string, - envOverride?: NodeJS.ProcessEnv, -): Promise<{ ok: boolean; exitCode: number; transcript: string; terminal: Block | null; blocks: Block[] }> { - return new Promise((resolve) => { - const child = spawn(cmd, args, { - stdio: ['ignore', 'pipe', 'pipe'], - env: envOverride ? { ...process.env, ...envOverride } : process.env, - }); - let transcript = ''; - const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); - raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); - const blocks: Block[] = []; - const stream = new StatusStream((b) => blocks.push(b)); - child.stdout.on('data', (c: Buffer) => { - const s = c.toString('utf-8'); - transcript += s; - stream.write(s); - raw.write(c); - }); - child.stderr.on('data', (c: Buffer) => { - transcript += c.toString('utf-8'); - raw.write(c); - }); - child.on('close', (code) => { - raw.end(); - const terminal = - [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; - resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); - }); - }); -} - -function dumpTranscriptOnFailure(transcript: string): void { - const lines = transcript.split('\n').filter((l) => { - if (l.startsWith('=== NANOCLAW SETUP:')) return false; - if (l.startsWith('=== END ===')) return false; - return true; - }); - const tail = lines.slice(-40).join('\n').trimEnd(); - if (tail) { - console.log(); - console.log(k.dim(tail)); - console.log(); - } -} - -function fail(msg: string, hint?: string): never { - setupLog.abort(failingStep, msg); - p.log.error(msg); - if (hint) p.log.message(k.dim(hint)); - p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); - p.cancel('Setup aborted.'); - process.exit(1); -} - -function ensureAnswer(value: T | symbol): T { - if (p.isCancel(value)) { - setupLog.abort(failingStep, 'user-cancelled'); - p.cancel('Setup cancelled.'); - process.exit(0); - } - return value as T; -} - -/** - * After installing Docker, this process's supplementary groups are still - * frozen from login — subsequent steps that talk to /var/run/docker.sock - * (onecli install, service start, …) fail with EACCES even though the - * daemon is up. Detect that and re-exec the whole driver under `sg docker` - * so the rest of the run inherits the docker group without a re-login. - */ -function maybeReexecUnderSg(): void { - if (process.env.NANOCLAW_REEXEC_SG === '1') return; - if (process.platform !== 'linux') return; - const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); - if (info.status === 0) return; - const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`; - if (!/permission denied/i.test(err)) return; - if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - - p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.'); - const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { - stdio: 'inherit', - env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, - }); - process.exit(res.status ?? 1); -} - -function anthropicSecretExists(): boolean { - try { - const res = spawnSync('onecli', ['secrets', 'list'], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (res.status !== 0) return false; - return /anthropic/i.test(res.stdout ?? ''); - } catch { - return false; - } -} - -function runInheritScript(cmd: string, args: string[]): Promise { - return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: 'inherit' }); - child.on('close', (code) => resolve(code ?? 1)); - }); -} - -function formatCodeCard(code: string): string { - const spaced = code.split('').join(' '); - return [ - '', - ` ${brandBold(spaced)}`, - '', - k.dim(' Send these digits from Telegram to your bot.'), - ].join('\n'); -} - -async function runPairTelegram(): Promise { - failingStep = 'pair-telegram'; - const rawLog = setupLog.stepRawLog('pair-telegram'); - const start = Date.now(); - const s = p.spinner(); - s.start('Creating pairing code…'); - let spinnerActive = true; - - const stopSpinner = (msg: string, code?: number) => { - if (spinnerActive) { - s.stop(msg, code); - spinnerActive = false; - } - }; - - const result = await spawnStep( - 'pair-telegram', - ['--intent', 'main'], - (block) => { - if (block.type === 'PAIR_TELEGRAM_CODE') { - const reason = block.fields.REASON ?? 'initial'; - if (reason === 'initial') { - stopSpinner('Pairing code ready.'); - } else { - stopSpinner('Previous code invalidated. New code below.'); - } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); - s.start('Waiting for the code from Telegram…'); - spinnerActive = true; - } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { - stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); - s.start('Waiting for the correct code…'); - spinnerActive = true; - } else if (block.type === 'PAIR_TELEGRAM') { - if (block.fields.STATUS === 'success') { - stopSpinner('Telegram paired.'); - } else { - stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); - } - } - }, - rawLog, - ); - const durationMs = Date.now() - start; - - // Safety net: if the child died without emitting a terminal block, make - // sure we don't leave the spinner running. - if (spinnerActive) { - stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1); - if (!result.ok) dumpTranscriptOnFailure(result.transcript); - } - - writeStepEntry('pair-telegram', result, durationMs, rawLog); - return { ...result, rawLog, durationMs }; -} - -async function askDisplayName(fallback: string): Promise { - const answer = ensureAnswer( - await p.text({ - message: 'What should your agents call you?', - placeholder: fallback, - defaultValue: fallback, - }), - ); - const value = (answer as string).trim() || fallback; - setupLog.userInput('display_name', value); - return value; -} - -async function askAgentName(fallback: string): Promise { - const answer = ensureAnswer( - await p.text({ - message: 'What should your messaging agent be called?', - placeholder: fallback, - defaultValue: fallback, - }), - ); - const value = (answer as string).trim() || fallback; - setupLog.userInput('agent_name', value); - return value; -} - -async function askChannelChoice(): Promise<'telegram' | 'skip'> { - const choice = ensureAnswer( - await p.select({ - message: 'Connect a messaging app so you can chat from your phone?', - options: [ - { value: 'telegram', label: 'Telegram', hint: 'recommended' }, - { value: 'skip', label: 'Skip — use the CLI only' }, - ], - }), - ); - setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'skip'; -} - -async function collectTelegramToken(): Promise { - p.note( - [ - '1. Open Telegram and message @BotFather', - '2. Send: /newbot', - '3. Follow the prompts (name + username ending in "bot")', - '4. Copy the token it gives you (format: :)', - '', - k.dim('Optional, but recommended for groups:'), - k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), - ].join('\n'), - 'Create a Telegram bot', - ); - - const answer = ensureAnswer( - await p.password({ - message: 'Paste your bot token', - validate: (v) => { - if (!v || !v.trim()) return 'Token is required'; - if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { - return 'Format looks wrong — expected :'; - } - return undefined; - }, - }), - ); - const token = (answer as string).trim(); - setupLog.userInput( - 'telegram_token', - `${token.slice(0, 12)}…${token.slice(-4)}`, - ); - return token; -} - -async function validateTelegramToken(token: string): Promise { - failingStep = 'telegram-validate'; - const s = p.spinner(); - const start = Date.now(); - s.start('Validating token with Telegram…'); - try { - const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); - const data = (await res.json()) as { - ok?: boolean; - result?: { username?: string; id?: number }; - description?: string; - }; - const elapsed = Math.round((Date.now() - start) / 1000); - if (data.ok && data.result?.username) { - const username = data.result.username; - s.stop(`Bot is @${username}. ${k.dim(`(${elapsed}s)`)}`); - setupLog.step( - 'telegram-validate', - 'success', - Date.now() - start, - { BOT_USERNAME: username, BOT_ID: data.result.id ?? '' }, - ); - return username; - } - const reason = data.description ?? 'token rejected by Telegram'; - s.stop(`Telegram rejected the token: ${reason}`, 1); - setupLog.step( - 'telegram-validate', - 'failed', - Date.now() - start, - { ERROR: reason }, - ); - fail( - 'Telegram rejected the token.', - 'Double-check the token (copy it again from @BotFather) and retry.', - ); - } catch (err) { - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`Could not reach Telegram. ${k.dim(`(${elapsed}s)`)}`, 1); - const message = err instanceof Error ? err.message : String(err); - setupLog.step('telegram-validate', 'failed', Date.now() - start, { - ERROR: message, - }); - fail( - 'Telegram API unreachable.', - 'Check your network connection and retry.', - ); - } -} - -function printIntro(): void { - const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; - const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; - - if (isReexec) { - p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); - return; - } - - console.log(); - console.log(` ${wordmark}`); - console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); - p.intro(`${brandChip(' setup:auto ')}`); -} async function main(): Promise { printIntro(); @@ -629,11 +45,11 @@ async function main(): Promise { ); if (!skip.has('environment')) { - const res = await runQuietStep( - 'environment', - { running: 'Checking environment…', done: 'Environment OK.' }, - ); - if (!res.ok) fail('Environment check failed.'); + const res = await runQuietStep('environment', { + running: 'Checking environment…', + done: 'Environment OK.', + }); + if (!res.ok) fail('environment', 'Environment check failed.'); } if (!skip.has('container')) { @@ -646,17 +62,20 @@ async function main(): Promise { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { fail( + 'container', 'Docker is not available and could not be started automatically.', 'Install Docker Desktop or start it manually, then retry.', ); } if (err === 'docker_group_not_active') { fail( + 'container', 'Docker was just installed but your shell is not yet in the `docker` group.', 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( + 'container', 'Container build/test failed.', 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', ); @@ -673,11 +92,13 @@ async function main(): Promise { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { fail( + 'onecli', 'OneCLI installed but not on PATH.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( + 'onecli', `OneCLI install failed (${err ?? 'unknown'}).`, 'Check that curl + a writable ~/.local/bin are available, then retry.', ); @@ -685,7 +106,6 @@ async function main(): Promise { } if (!skip.has('auth')) { - failingStep = 'auth'; if (anthropicSecretExists()) { p.log.success('OneCLI already has an Anthropic secret — skipping.'); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); @@ -702,22 +122,29 @@ async function main(): Promise { if (code !== 0) { setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); fail( + 'auth', 'Anthropic credential registration failed or was aborted.', 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', ); } - setupLog.step('auth', 'interactive', durationMs, { METHOD: 'register-claude-token.sh' }); + setupLog.step('auth', 'interactive', durationMs, { + METHOD: 'register-claude-token.sh', + }); p.log.success('Anthropic credential registered with OneCLI.'); } } if (!skip.has('mounts')) { - const res = await runQuietStep('mounts', { - running: 'Writing mount allowlist…', - done: 'Mount allowlist in place.', - skipped: 'Mount allowlist already configured.', - }, ['--empty']); - if (!res.ok) fail('Mount allowlist step failed.'); + const res = await runQuietStep( + 'mounts', + { + running: 'Writing mount allowlist…', + done: 'Mount allowlist in place.', + skipped: 'Mount allowlist already configured.', + }, + ['--empty'], + ); + if (!res.ok) fail('mounts', 'Mount allowlist step failed.'); } if (!skip.has('service')) { @@ -727,6 +154,7 @@ async function main(): Promise { }); if (!res.ok) { fail( + 'service', 'Service install failed.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', ); @@ -761,6 +189,7 @@ async function main(): Promise { ); if (!res.ok) { fail( + 'cli-agent', 'CLI agent wiring failed.', `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); @@ -770,75 +199,7 @@ async function main(): Promise { if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { - const token = await collectTelegramToken(); - const botUsername = await validateTelegramToken(token); - - const install = await runQuietChild( - 'telegram-install', - 'bash', - ['setup/add-telegram.sh'], - { - running: `Installing Telegram adapter and wiring @${botUsername}…`, - done: `Telegram adapter ready.`, - }, - { - env: { TELEGRAM_BOT_TOKEN: token }, - extraFields: { BOT_USERNAME: botUsername }, - }, - ); - if (!install.ok) { - fail( - 'Telegram install failed.', - 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', - ); - } - - const pair = await runPairTelegram(); - if (!pair.ok) { - fail( - 'Telegram pairing failed.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', - ); - } - - const platformId = pair.terminal?.fields.PLATFORM_ID; - const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; - if (!platformId || !pairedUserId) { - fail( - 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', - ); - } - - const agentName = - process.env.NANOCLAW_AGENT_NAME?.trim() || - (await askAgentName(DEFAULT_AGENT_NAME)); - - const init = await runQuietChild( - 'init-first-agent', - 'pnpm', - [ - 'exec', 'tsx', 'scripts/init-first-agent.ts', - '--channel', 'telegram', - '--user-id', pairedUserId, - '--platform-id', platformId, - '--display-name', displayName!, - '--agent-name', agentName, - ], - { - running: `Wiring ${agentName} to your Telegram chat…`, - done: `${agentName} is wired — welcome DM incoming.`, - }, - { - extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, - }, - ); - if (!init.ok) { - fail( - 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, - ); - } + await runTelegramChannel(displayName!); } else { p.log.info('No messaging channel wired — you can add one later with `/add-`.'); } @@ -883,6 +244,98 @@ async function main(): Promise { p.outro(k.green('Setup complete.')); } +// ─── prompts owned by the sequencer ──────────────────────────────────── + +async function askDisplayName(fallback: string): Promise { + const answer = ensureAnswer( + await p.text({ + message: 'What should your agents call you?', + placeholder: fallback, + defaultValue: fallback, + }), + ); + const value = (answer as string).trim() || fallback; + setupLog.userInput('display_name', value); + return value; +} + +async function askChannelChoice(): Promise<'telegram' | 'skip'> { + const choice = ensureAnswer( + await p.select({ + message: 'Connect a messaging app so you can chat from your phone?', + options: [ + { value: 'telegram', label: 'Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip — use the CLI only' }, + ], + }), + ); + setupLog.userInput('channel_choice', String(choice)); + return choice as 'telegram' | 'skip'; +} + +// ─── interactive / env helpers ───────────────────────────────────────── + +function anthropicSecretExists(): boolean { + try { + const res = spawnSync('onecli', ['secrets', 'list'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return false; + return /anthropic/i.test(res.stdout ?? ''); + } catch { + return false; + } +} + +function runInheritScript(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: 'inherit' }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +/** + * After installing Docker, this process's supplementary groups are still + * frozen from login — subsequent steps that talk to /var/run/docker.sock + * (onecli install, service start, …) fail with EACCES even though the + * daemon is up. Detect that and re-exec the whole driver under `sg docker` + * so the rest of the run inherits the docker group without a re-login. + */ +function maybeReexecUnderSg(): void { + if (process.env.NANOCLAW_REEXEC_SG === '1') return; + if (process.platform !== 'linux') return; + const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); + if (info.status === 0) return; + const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`; + if (!/permission denied/i.test(err)) return; + if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; + + p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.'); + const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { + stdio: 'inherit', + env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + }); + process.exit(res.status ?? 1); +} + +// ─── intro + progression-log init ────────────────────────────────────── + +function printIntro(): void { + const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; + + if (isReexec) { + p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); + return; + } + + console.log(); + console.log(` ${wordmark}`); + console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); + p.intro(`${brandChip(' setup:auto ')}`); +} + /** * Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes * the bootstrap entry before we even boot. If someone runs `pnpm run diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts new file mode 100644 index 0000000..d3e3f89 --- /dev/null +++ b/setup/channels/telegram.ts @@ -0,0 +1,277 @@ +/** + * Telegram channel flow for setup:auto. + * + * `runTelegramChannel(displayName)` owns the full branch from the + * BotFather instructions through the welcome DM: + * + * 1. BotFather instructions (clack note) + * 2. Paste the bot token (clack password) — format-validated + * 3. getMe via the Bot API to resolve the bot's username + * 4. Install the adapter (setup/add-telegram.sh, non-interactive) + * 5. Run the pair-telegram step, rendering code events as clack notes + * 6. Ask for the messaging-agent name (defaulting to "Nano") + * 7. Wire the agent via scripts/init-first-agent.ts + * + * All output obeys the three-level contract: clack UI for the user, + * structured entries in logs/setup.log, full raw output in per-step files + * under logs/setup-steps/. See docs/setup-flow.md. + */ +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { brandBold } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runTelegramChannel(displayName: string): Promise { + const token = await collectTelegramToken(); + const botUsername = await validateTelegramToken(token); + + const install = await runQuietChild( + 'telegram-install', + 'bash', + ['setup/add-telegram.sh'], + { + running: `Installing Telegram adapter and wiring @${botUsername}…`, + done: 'Telegram adapter ready.', + }, + { + env: { TELEGRAM_BOT_TOKEN: token }, + extraFields: { BOT_USERNAME: botUsername }, + }, + ); + if (!install.ok) { + fail( + 'telegram-install', + 'Telegram install failed.', + 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', + ); + } + + const pair = await runPairTelegram(); + if (!pair.ok) { + fail( + 'pair-telegram', + 'Telegram pairing failed.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + ); + } + + const platformId = pair.terminal?.fields.PLATFORM_ID; + const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; + if (!platformId || !pairedUserId) { + fail( + 'pair-telegram', + 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', + ); + } + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'telegram', + '--user-id', pairedUserId, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + ], + { + running: `Wiring ${agentName} to your Telegram chat…`, + done: `${agentName} is wired — welcome DM incoming.`, + }, + { + extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + 'Wiring the Telegram agent failed.', + `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName}" --agent-name "${agentName}"\`.`, + ); + } +} + +async function collectTelegramToken(): Promise { + p.note( + [ + '1. Open Telegram and message @BotFather', + '2. Send: /newbot', + '3. Follow the prompts (name + username ending in "bot")', + '4. Copy the token it gives you (format: :)', + '', + k.dim('Optional, but recommended for groups:'), + k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ].join('\n'), + 'Create a Telegram bot', + ); + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + if (!v || !v.trim()) return 'Token is required'; + if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return 'Format looks wrong — expected :'; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'telegram_token', + `${token.slice(0, 12)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateTelegramToken(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Validating token with Telegram…'); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const data = (await res.json()) as { + ok?: boolean; + result?: { username?: string; id?: number }; + description?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.result?.username) { + const username = data.result.username; + s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('telegram-validate', 'success', Date.now() - start, { + BOT_USERNAME: username, + BOT_ID: data.result.id ?? '', + }); + return username; + } + const reason = data.description ?? 'token rejected by Telegram'; + s.stop(`Telegram rejected the token: ${reason}`, 1); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'telegram-validate', + 'Telegram rejected the token.', + 'Double-check the token (copy it again from @BotFather) and retry.', + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'telegram-validate', + 'Telegram API unreachable.', + 'Check your network connection and retry.', + ); + } +} + +async function runPairTelegram(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('pair-telegram'); + const start = Date.now(); + const s = p.spinner(); + s.start('Creating pairing code…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number) => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + const result = await spawnStep( + 'pair-telegram', + ['--intent', 'main'], + (block: Block) => { + if (block.type === 'PAIR_TELEGRAM_CODE') { + const reason = block.fields.REASON ?? 'initial'; + if (reason === 'initial') { + stopSpinner('Pairing code ready.'); + } else { + stopSpinner('Previous code invalidated. New code below.'); + } + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); + s.start('Waiting for the code from Telegram…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { + stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + s.start('Waiting for the correct code…'); + spinnerActive = true; + } else if (block.type === 'PAIR_TELEGRAM') { + if (block.fields.STATUS === 'success') { + stopSpinner('Telegram paired.'); + } else { + stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + // Safety net: if the child died without emitting a terminal block, make + // sure we don't leave the spinner running. + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Pairing exited unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('pair-telegram', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +function formatCodeCard(code: string): string { + const spaced = code.split('').join(' '); + return [ + '', + ` ${brandBold(spaced)}`, + '', + k.dim(' Send these digits from Telegram to your bot.'), + ].join('\n'); +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your messaging agent be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts new file mode 100644 index 0000000..59b3da6 --- /dev/null +++ b/setup/lib/runner.ts @@ -0,0 +1,325 @@ +/** + * Step runner + abort helpers for setup:auto. + * + * Responsibilities: + * - Stream-parse setup-step status blocks (`=== NANOCLAW SETUP: … ===`) + * - Spawn children with output tee'd to a per-step raw log (level 3) + * - Wrap each run in a clack spinner with live elapsed time (level 1) + * - Append a structured entry to the progression log (level 2) via + * `setup/logs.ts` when the run ends + * - Abort helpers (`fail`, `ensureAnswer`) used by step orchestrators + * + * See docs/setup-flow.md for the three-level output contract. + */ +import { spawn } from 'child_process'; +import fs from 'fs'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; + +export type Fields = Record; +export type Block = { type: string; fields: Fields }; + +export type StepResult = { + ok: boolean; + exitCode: number; + blocks: Block[]; + transcript: string; + /** The last block with a STATUS field (the terminal/result block). */ + terminal: Block | null; +}; + +export type QuietChildResult = { + ok: boolean; + exitCode: number; + transcript: string; + terminal: Block | null; + blocks: Block[]; +}; + +export type SpinnerLabels = { + running: string; + done: string; + skipped?: string; + failed?: string; +}; + +/** + * Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each + * block as it closes so the UI can react mid-stream (e.g. render a pairing + * code card as soon as pair-telegram emits it, rather than after the step + * has finished). + */ +export class StatusStream { + private lineBuf = ''; + private current: Block | null = null; + readonly blocks: Block[] = []; + transcript = ''; + + constructor(private readonly onBlock: (block: Block) => void) {} + + write(chunk: string): void { + this.transcript += chunk; + this.lineBuf += chunk; + let idx: number; + while ((idx = this.lineBuf.indexOf('\n')) !== -1) { + const line = this.lineBuf.slice(0, idx); + this.lineBuf = this.lineBuf.slice(idx + 1); + this.processLine(line); + } + } + + private processLine(line: string): void { + const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/); + if (start) { + this.current = { type: start[1], fields: {} }; + return; + } + if (line.startsWith('=== END ===')) { + if (this.current) { + this.blocks.push(this.current); + this.onBlock(this.current); + this.current = null; + } + return; + } + if (!this.current) return; + const colon = line.indexOf(':'); + if (colon === -1) return; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key) this.current.fields[key] = value; + } +} + +/** + * Spawn a setup step as a child process. Output is tee'd to the provided + * raw log file (level 3) and parsed for status blocks (level 2 summary). + * The onBlock callback fires per status block as they close so the UI can + * react mid-stream. + */ +export function spawnStep( + stepName: string, + extra: string[], + onBlock: (block: Block) => void, + rawLogPath: string, +): Promise { + return new Promise((resolve) => { + const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; + if (extra.length > 0) args.push('--', ...extra); + + const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stream = new StatusStream(onBlock); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); + + child.stdout.on('data', (chunk: Buffer) => { + stream.write(chunk.toString('utf-8')); + raw.write(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stream.transcript += chunk.toString('utf-8'); + raw.write(chunk); + }); + + child.on('close', (code) => { + raw.end(); + const terminal = + [...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null; + const status = terminal?.fields.STATUS; + const ok = code === 0 && (status === 'success' || status === 'skipped'); + resolve({ + ok, + exitCode: code ?? 1, + blocks: stream.blocks, + transcript: stream.transcript, + terminal, + }); + }); + }); +} + +export function spawnQuiet( + cmd: string, + args: string[], + rawLogPath: string, + envOverride?: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: envOverride ? { ...process.env, ...envOverride } : process.env, + }); + let transcript = ''; + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); + const blocks: Block[] = []; + const stream = new StatusStream((b) => blocks.push(b)); + child.stdout.on('data', (c: Buffer) => { + const s = c.toString('utf-8'); + transcript += s; + stream.write(s); + raw.write(c); + }); + child.stderr.on('data', (c: Buffer) => { + transcript += c.toString('utf-8'); + raw.write(c); + }); + child.on('close', (code) => { + raw.end(); + const terminal = + [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; + resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); + }); + }); +} + +/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ +export async function runQuietStep( + stepName: string, + labels: SpinnerLabels, + extra: string[] = [], +): Promise { + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnStep(stepName, extra, () => {}, rawLog), + ); + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ +export async function runQuietChild( + logName: string, + cmd: string, + args: string[], + labels: SpinnerLabels, + opts?: { + /** Extra fields to merge into the progression entry (on top of any status-block fields). */ + extraFields?: Record; + /** Environment overrides to pass to the child process. */ + env?: NodeJS.ProcessEnv; + }, +): Promise { + const rawLog = setupLog.stepRawLog(logName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnQuiet(cmd, args, rawLog, opts?.env), + ); + const durationMs = Date.now() - start; + + const blockFields = summariseTerminalFields(result.terminal); + const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; + const rawStatus = result.terminal?.fields.STATUS; + const status: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + setupLog.step(logName, status, durationMs, fields, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Turn a step's terminal-block fields into a concise progression-log entry. */ +export function writeStepEntry( + stepName: string, + result: StepResult, + durationMs: number, + rawLog: string, +): void { + const rawStatus = result.terminal?.fields.STATUS; + const logStatus: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + const fields = summariseTerminalFields(result.terminal); + setupLog.step(stepName, logStatus, durationMs, fields, rawLog); +} + +/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ +export function summariseTerminalFields(block: Block | null): Record { + if (!block) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(block.fields)) { + if (k === 'STATUS' || k === 'LOG') continue; + if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log + out[k] = v; + } + return out; +} + +async function runUnderSpinner< + T extends { ok: boolean; transcript: string; terminal?: Block | null }, +>( + labels: SpinnerLabels, + work: () => Promise, +): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start(labels.running); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`); + }, 1000); + + const result = await work(); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + if (result.ok) { + const isSkipped = result.terminal?.fields.STATUS === 'skipped'; + const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; + s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`); + } else { + const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); + s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1); + dumpTranscriptOnFailure(result.transcript); + } + return result; +} + +export function dumpTranscriptOnFailure(transcript: string): void { + const lines = transcript.split('\n').filter((l) => { + if (l.startsWith('=== NANOCLAW SETUP:')) return false; + if (l.startsWith('=== END ===')) return false; + return true; + }); + const tail = lines.slice(-40).join('\n').trimEnd(); + if (tail) { + console.log(); + console.log(k.dim(tail)); + console.log(); + } +} + +/** + * Abort the setup run with a user-facing error, logging the abort to the + * progression log. Takes the step name explicitly so callers are clear + * about which step they're failing from — no hidden module state. + */ +export function fail(stepName: string, msg: string, hint?: string): never { + setupLog.abort(stepName, msg); + p.log.error(msg); + if (hint) p.log.message(k.dim(hint)); + p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); + p.cancel('Setup aborted.'); + process.exit(1); +} + +/** + * Unwrap a clack prompt result. If the user cancelled (Ctrl-C / Esc), exit + * gracefully. Cancel is exit 0 — it's not an abort worth logging to the + * progression log, since the operator initiated it deliberately. + */ +export function ensureAnswer(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + return value as T; +} diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts new file mode 100644 index 0000000..9bd18a5 --- /dev/null +++ b/setup/lib/theme.ts @@ -0,0 +1,39 @@ +/** + * NanoClaw brand palette for the terminal. + * + * Colors pulled from assets/nanoclaw-logo.png: + * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body + * brand navy ≈ #171B3B — the dark logo background + outlines + * + * Rendering gates: + * - No TTY (piped / redirected) → plain text, no ANSI + * - NO_COLOR set → plain text, no ANSI + * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) + * - Otherwise → kleur's 16-color cyan (closest fallback) + */ +import k from 'kleur'; + +const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +const TRUECOLOR = + USE_ANSI && + (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit'); + +export function brand(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`; + return k.cyan(s); +} + +export function brandBold(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`; + return k.bold(k.cyan(s)); +} + +export function brandChip(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) { + return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`; + } + return k.bgCyan(k.black(k.bold(s))); +}