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:
813
setup/auto.ts
813
setup/auto.ts
@@ -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
277
setup/channels/telegram.ts
Normal 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
325
setup/lib/runner.ts
Normal 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
39
setup/lib/theme.ts
Normal 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)));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user