refactor(setup): split auto.ts into runner + theme + telegram channel

auto.ts had grown to 923 lines with ~10 interleaved responsibilities.
Split into three focused modules, keeping auto.ts as a pure step
sequencer:

- setup/lib/runner.ts (325 lines) — spawn + stream-parse + spinner-wrap
  primitives. Exports: spawnStep, spawnQuiet, runQuietStep,
  runQuietChild, runUnderSpinner (internal), StatusStream, types
  (Fields, Block, StepResult, SpinnerLabels, QuietChildResult),
  writeStepEntry, summariseTerminalFields, dumpTranscriptOnFailure,
  fail(), ensureAnswer().

- setup/lib/theme.ts (39 lines) — brand palette (brand, brandBold,
  brandChip) with USE_ANSI / TRUECOLOR gating, so both auto.ts and
  channel flows can render the NanoClaw cyan without duplicating the
  detection.

- setup/channels/telegram.ts (277 lines) — runTelegramChannel(displayName)
  owns the full flow: BotFather instructions, token paste + validation
  (via getMe), install script, pair-telegram streaming UI (code card +
  attempt checkpoints), agent-name prompt, init-first-agent wiring.

auto.ts drops to 376 lines. main() reads as a clean sequence of
`if (!skip.has(X)) await Xstep(...)` blocks.

fail() now takes the step name explicitly — no module-level
failingStep state. Every call site is grep-friendly and self-contained
(fail('container', msg, hint)).

Typechecks clean. Smoke-tested end-to-end: intro, mounts step,
progression log, and outro all render the same as before the split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-22 02:26:50 +03:00
parent 416fe01855
commit 9b7d4d50e4
4 changed files with 774 additions and 680 deletions

View File

@@ -1,621 +1,37 @@
/** /**
* Non-interactive setup driver. Chains the deterministic setup steps so a * Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`.
* 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 * Responsibility: orchestrate the sequence of steps end-to-end and route
* module check). This driver picks up from there. * 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: * Config via env:
* NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the * NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the
* prompt. Defaults to $USER. * prompt. Defaults to $USER.
* NANOCLAW_AGENT_NAME name for the messaging-channel agent (Telegram, * NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
* etc.) — skips the prompt. Defaults to "Nano". * channel flow). The CLI scratch agent is always
* (The CLI scratch agent is always "Terminal Agent".) * "Terminal Agent".
* NANOCLAW_SKIP comma-separated step names to skip * NANOCLAW_SKIP comma-separated step names to skip
* (environment|container|onecli|auth|mounts| * (environment|container|onecli|auth|mounts|
* service|cli-agent|channel|verify) * service|cli-agent|channel|verify)
* *
* Timezone is not configured here — it defaults to the host system's TZ. * Timezone defaults to the host system's TZ. Run
* Run `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>` later * pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>
* if autodetect is wrong (e.g. headless server with TZ=UTC). * later if autodetect is wrong.
*
* 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.
*/ */
import { spawn, spawnSync } from 'child_process'; import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import { runTelegramChannel } from './channels/telegram.js';
import * as setupLog from './logs.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 CLI_AGENT_NAME = 'Terminal Agent';
const DEFAULT_AGENT_NAME = 'Nano';
const RUN_START = Date.now(); 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<string, string>;
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<StepResult> {
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<StepResult & { rawLog: string; durationMs: number }> {
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<string, string | number | boolean>;
/** 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<string, string> {
if (!block) return {};
const out: Record<string, string> = {};
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<T>,
): Promise<T> {
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<T>(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<number> {
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<StepResult & { rawLog: string; durationMs: number }> {
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<string> {
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<string> {
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<string> {
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: <digits>:<chars>)',
'',
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 <digits>:<chars>';
}
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<string> {
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<void> { async function main(): Promise<void> {
printIntro(); printIntro();
@@ -629,11 +45,11 @@ async function main(): Promise<void> {
); );
if (!skip.has('environment')) { if (!skip.has('environment')) {
const res = await runQuietStep( const res = await runQuietStep('environment', {
'environment', running: 'Checking environment',
{ running: 'Checking environment…', done: 'Environment OK.' }, done: 'Environment OK.',
); });
if (!res.ok) fail('Environment check failed.'); if (!res.ok) fail('environment', 'Environment check failed.');
} }
if (!skip.has('container')) { if (!skip.has('container')) {
@@ -646,17 +62,20 @@ async function main(): Promise<void> {
const err = res.terminal?.fields.ERROR; const err = res.terminal?.fields.ERROR;
if (err === 'runtime_not_available') { if (err === 'runtime_not_available') {
fail( fail(
'container',
'Docker is not available and could not be started automatically.', 'Docker is not available and could not be started automatically.',
'Install Docker Desktop or start it manually, then retry.', 'Install Docker Desktop or start it manually, then retry.',
); );
} }
if (err === 'docker_group_not_active') { if (err === 'docker_group_not_active') {
fail( fail(
'container',
'Docker was just installed but your shell is not yet in the `docker` group.', '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.', 'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
); );
} }
fail( fail(
'container',
'Container build/test failed.', 'Container build/test failed.',
'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.',
); );
@@ -673,11 +92,13 @@ async function main(): Promise<void> {
const err = res.terminal?.fields.ERROR; const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') { if (err === 'onecli_not_on_path_after_install') {
fail( fail(
'onecli',
'OneCLI installed but not on PATH.', 'OneCLI installed but not on PATH.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
); );
} }
fail( fail(
'onecli',
`OneCLI install failed (${err ?? 'unknown'}).`, `OneCLI install failed (${err ?? 'unknown'}).`,
'Check that curl + a writable ~/.local/bin are available, then retry.', 'Check that curl + a writable ~/.local/bin are available, then retry.',
); );
@@ -685,7 +106,6 @@ async function main(): Promise<void> {
} }
if (!skip.has('auth')) { if (!skip.has('auth')) {
failingStep = 'auth';
if (anthropicSecretExists()) { if (anthropicSecretExists()) {
p.log.success('OneCLI already has an Anthropic secret — skipping.'); p.log.success('OneCLI already has an Anthropic secret — skipping.');
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
@@ -702,22 +122,29 @@ async function main(): Promise<void> {
if (code !== 0) { if (code !== 0) {
setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code });
fail( fail(
'auth',
'Anthropic credential registration failed or was aborted.', 'Anthropic credential registration failed or was aborted.',
'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', '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.'); p.log.success('Anthropic credential registered with OneCLI.');
} }
} }
if (!skip.has('mounts')) { if (!skip.has('mounts')) {
const res = await runQuietStep('mounts', { const res = await runQuietStep(
running: 'Writing mount allowlist…', 'mounts',
done: 'Mount allowlist in place.', {
skipped: 'Mount allowlist already configured.', running: 'Writing mount allowlist…',
}, ['--empty']); done: 'Mount allowlist in place.',
if (!res.ok) fail('Mount allowlist step failed.'); skipped: 'Mount allowlist already configured.',
},
['--empty'],
);
if (!res.ok) fail('mounts', 'Mount allowlist step failed.');
} }
if (!skip.has('service')) { if (!skip.has('service')) {
@@ -727,6 +154,7 @@ async function main(): Promise<void> {
}); });
if (!res.ok) { if (!res.ok) {
fail( fail(
'service',
'Service install failed.', 'Service install failed.',
'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.',
); );
@@ -761,6 +189,7 @@ async function main(): Promise<void> {
); );
if (!res.ok) { if (!res.ok) {
fail( fail(
'cli-agent',
'CLI agent wiring failed.', 'CLI agent wiring failed.',
`Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, `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<void> {
if (!skip.has('channel')) { if (!skip.has('channel')) {
const choice = await askChannelChoice(); const choice = await askChannelChoice();
if (choice === 'telegram') { if (choice === 'telegram') {
const token = await collectTelegramToken(); await runTelegramChannel(displayName!);
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}"\`.`,
);
}
} else { } else {
p.log.info('No messaging channel wired — you can add one later with `/add-<channel>`.'); p.log.info('No messaging channel wired — you can add one later with `/add-<channel>`.');
} }
@@ -883,6 +244,98 @@ async function main(): Promise<void> {
p.outro(k.green('Setup complete.')); p.outro(k.green('Setup complete.'));
} }
// ─── prompts owned by the sequencer ────────────────────────────────────
async function askDisplayName(fallback: string): Promise<string> {
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<number> {
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 * Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes
* the bootstrap entry before we even boot. If someone runs `pnpm run * the bootstrap entry before we even boot. If someone runs `pnpm run

277
setup/channels/telegram.ts Normal file
View File

@@ -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<void> {
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<string> {
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: <digits>:<chars>)',
'',
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 <digits>:<chars>';
}
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<string> {
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<string> {
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;
}

325
setup/lib/runner.ts Normal file
View File

@@ -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<string, string>;
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<StepResult> {
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<QuietChildResult> {
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<StepResult & { rawLog: string; durationMs: number }> {
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<string, string | number | boolean>;
/** Environment overrides to pass to the child process. */
env?: NodeJS.ProcessEnv;
},
): Promise<QuietChildResult & { rawLog: string; durationMs: number }> {
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<string, string> {
if (!block) return {};
const out: Record<string, string> = {};
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<T>,
): Promise<T> {
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<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Setup cancelled.');
process.exit(0);
}
return value as T;
}

39
setup/lib/theme.ts Normal file
View File

@@ -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)));
}