feat(setup): three-level output (clack UI / progression log / raw per-step)

Documents and implements the output contract from docs/setup-flow.md:

  Level 1: clack UI — branded, concise, product content
  Level 2: logs/setup.log — append-only, linear, structured entries for
           humans + AI agents reviewing a run
  Level 3: logs/setup-steps/NN-name.log — full raw stdout+stderr per step

Every scripted sub-step, including bootstrap, emits at all three levels.
Bootstrap now runs under a bash-side clack-alike spinner with live elapsed
time; its apt/pnpm output is captured to 01-bootstrap.log and summarised
as a progression entry. setup.sh's legacy log() routes to the raw log
instead of contaminating the progression log.

Telegram install becomes fully branded: setup/auto.ts owns the BotFather
instructions (clack note), token paste (clack password with format
validation), and getMe check (clack spinner). add-telegram.sh drops to a
non-interactive installer that reads TELEGRAM_BOT_TOKEN from env, logs to
stderr, and emits a single ADD_TELEGRAM status block on stdout.

The Anthropic credential flow is the one intentional break — register-
claude-token.sh still inherits the TTY for claude setup-token's browser
dance; it logs as an 'interactive' progression entry with the method.

setup/logs.ts centralises the level 2/3 formatting: reset, header, step,
userInput, complete, abort, stepRawLog. User answers (display name, agent
name, channel choice, telegram_token preview) log as their own entries so
the setup path is reconstructable from the progression log alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-22 02:02:13 +03:00
parent 6e0d742a7f
commit 5269edada4
6 changed files with 909 additions and 151 deletions

View File

@@ -1,12 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
# Install the Telegram adapter (Phase A of the /add-telegram skill), collect
# the bot token, write .env + data/env/env, and restart the service so the
# new adapter is live. Idempotent.
#
# Pair-telegram (the interactive code-sending step) is run separately by the
# caller (setup/auto.ts) so it can stream status blocks to the user.
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
# restart the service, and open the bot's chat page in the local Telegram
# client. Non-interactive — the operator-facing "Create a bot" instructions
# and token paste live in setup/auto.ts. The token comes in via the
# TELEGRAM_BOT_TOKEN env var.
#
# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All
# chatty progress messages go to stderr so setup:auto's raw-log capture
# sees the full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
@@ -15,19 +18,49 @@ cd "$PROJECT_ROOT"
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local username=${BOT_USERNAME:-}
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-telegram] $*" >&2; }
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
exit 1
fi
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
emit_status failed "token format invalid (expected <digits>:<chars>)"
exit 1
fi
need_install() {
[[ ! -f src/channels/telegram.ts ]] && return 0
[ ! -f src/channels/telegram.ts ] && return 0
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
echo "[add-telegram] Fetching channels branch…"
git fetch origin channels >/dev/null 2>&1
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
# in this list — do not overwrite the local version with the channels copy.
echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}"
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/telegram.ts \
src/channels/telegram-pairing.ts \
@@ -35,7 +68,7 @@ if need_install; then
src/channels/telegram-markdown-sanitize.ts \
src/channels/telegram-markdown-sanitize.test.ts
do
git show "$CHANNELS_BRANCH:$f" > "$f"
git show "${CHANNELS_BRANCH}:$f" > "$f"
done
# Append self-registration import if missing.
@@ -59,109 +92,71 @@ if need_install; then
}
'
echo "[add-telegram] Installing ${ADAPTER_VERSION}"
pnpm install "$ADAPTER_VERSION"
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
echo "[add-telegram] Building…"
pnpm run build >/dev/null
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
echo "[add-telegram] Adapter files already installed — skipping install phase."
log "Adapter files already installed — skipping install phase."
fi
# Token collection.
if grep -q '^TELEGRAM_BOT_TOKEN=.' .env 2>/dev/null; then
echo "[add-telegram] TELEGRAM_BOT_TOKEN already set in .env — skipping token prompt."
# Persist token. auto.ts validates before this point, so a bad token here
# would be an internal bug rather than operator input.
touch .env
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
awk -v tok="$TELEGRAM_BOT_TOKEN" \
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
cat <<'EOF'
── Create a Telegram bot ──────────────────────────────────────
1. Open Telegram and message @BotFather
2. Send: /newbot
3. Follow the prompts (bot name, username ending in "bot")
4. Copy the token it gives you (format: <digits>:<chars>)
Optional but recommended for groups:
5. @BotFather → /mybots → your bot → Bot Settings → Group Privacy → OFF
EOF
echo "Paste your TELEGRAM_BOT_TOKEN and press Enter."
echo "Nothing will appear on the screen as you paste — that's intentional."
echo "Paste once, then just press Enter to submit."
read -r -s -p "> " TOKEN </dev/tty
echo
if [[ -z "$TOKEN" ]]; then
echo "[add-telegram] No token entered. Aborting." >&2
exit 1
fi
# Telegram bot tokens: <digits>:<35+ base64url-ish chars>.
if [[ ! "$TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
echo "[add-telegram] Token format looks wrong (expected <digits>:<chars>). Aborting." >&2
exit 1
fi
touch .env
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
awk -v tok="$TOKEN" '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "TELEGRAM_BOT_TOKEN=$TOKEN" >> .env
fi
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
fi
# Validate the token via getMe so a typo surfaces before we restart the
# service, and capture the bot's username for the deep link.
TELEGRAM_BOT_TOKEN_VALUE="$(grep '^TELEGRAM_BOT_TOKEN=' .env | head -1 | cut -d= -f2-)"
# Look up the bot username (auto.ts already validated; we re-query here so
# standalone invocations still work).
INFO=$(curl -fsS --max-time 8 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
BOT_USERNAME=""
if [[ -n "$TELEGRAM_BOT_TOKEN_VALUE" ]]; then
INFO=$(curl -fsS --max-time 8 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN_VALUE}/getMe" 2>/dev/null || true)
if echo "$INFO" | grep -q '"ok":true'; then
# Crude JSON parse — the response is always a flat object here.
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
if [[ -n "$BOT_USERNAME" ]]; then
echo "[add-telegram] Token validated — bot is @${BOT_USERNAME}."
fi
else
echo "[add-telegram] Warning: getMe did not return ok. Continuing, but the token may be wrong."
fi
if echo "$INFO" | grep -q '"ok":true'; then
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
# Deep-link into the bot's chat in the installed Telegram app so the user
# is already on the right screen when pair-telegram prints the code. Also
# always print the URL so headless / remote-SSH users can open it manually.
if [[ -n "$BOT_USERNAME" ]]; then
BOT_URL="https://t.me/${BOT_USERNAME}"
# Deep-link into the bot's chat so the user is already on the right screen
# when pair-telegram prints the code. Silent best-effort — runs under a
# spinner, any output (from `open` / `xdg-open`) goes to the raw log.
if [ -n "$BOT_USERNAME" ]; then
case "$(uname -s)" in
Darwin)
open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \
|| open "$BOT_URL" >/dev/null 2>&1 \
open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \
|| open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \
|| true
;;
Linux)
xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \
|| xdg-open "$BOT_URL" >/dev/null 2>&1 \
xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \
|| xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \
|| true
;;
esac
echo "[add-telegram] Bot chat: ${BOT_URL}"
echo "[add-telegram] (If Telegram didn't open automatically, click the link above.)"
fi
echo "[add-telegram] Restarting service so the new adapter picks up the token…"
log "Restarting service so the new adapter picks up the token…"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >/dev/null 2>&1 || true
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart nanoclaw >/dev/null 2>&1 \
|| sudo systemctl restart nanoclaw >/dev/null 2>&1 \
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|| true
;;
esac
@@ -170,4 +165,4 @@ esac
# begins polling for the user's code message.
sleep 5
echo "[add-telegram] Install + credentials complete."
emit_status success

View File

@@ -26,13 +26,19 @@
* with inherited stdio — clack resumes cleanly on the next step.
*/
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from './logs.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const DEFAULT_AGENT_NAME = 'Nano';
const RUN_START = Date.now();
let failingStep = 'setup';
/**
* Brand palette, pulled from assets/nanoclaw-logo.png:
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
@@ -123,14 +129,16 @@ class StatusStream {
}
/**
* Spawn a setup step as a child process, swallowing stdout/stderr into a
* buffer. The provided onBlock callback fires per status block as they
* parse. Returns when the child exits.
* 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];
@@ -138,13 +146,20 @@ function spawnStep(
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')));
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.
@@ -170,22 +185,90 @@ type SpinnerLabels = {
failed?: string;
};
/** Run a step under a clack spinner. Child output is captured; shown only on failure. */
/** 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> {
return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {}));
): 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, capturing its stdout/stderr. */
/** 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,
): Promise<{ ok: boolean; exitCode: number; transcript: string }> {
return runUnderSpinner(labels, () => spawnQuiet(cmd, args));
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<
@@ -221,14 +304,34 @@ async function runUnderSpinner<
function spawnQuiet(
cmd: string,
args: string[],
): Promise<{ ok: boolean; exitCode: number; transcript: 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'] });
const child = spawn(cmd, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: envOverride ? { ...process.env, ...envOverride } : process.env,
});
let transcript = '';
child.stdout.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); });
child.stderr.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); });
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) => {
resolve({ ok: code === 0, exitCode: code ?? 1, transcript });
raw.end();
const terminal =
[...blocks].reverse().find((b) => b.fields.STATUS) ?? null;
resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks });
});
});
}
@@ -248,15 +351,17 @@ function dumpTranscriptOnFailure(transcript: string): void {
}
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'));
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);
}
@@ -317,7 +422,10 @@ function formatCodeCard(code: string): string {
].join('\n');
}
async function runPairTelegram(): Promise<StepResult> {
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;
@@ -329,29 +437,35 @@ async function runPairTelegram(): Promise<StepResult> {
}
};
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.');
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);
}
}
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.
@@ -359,7 +473,9 @@ async function runPairTelegram(): Promise<StepResult> {
stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
return result;
writeStepEntry('pair-telegram', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
async function askDisplayName(fallback: string): Promise<string> {
@@ -370,7 +486,9 @@ async function askDisplayName(fallback: string): Promise<string> {
defaultValue: fallback,
}),
);
return (answer as string).trim() || fallback;
const value = (answer as string).trim() || fallback;
setupLog.userInput('display_name', value);
return value;
}
async function askAgentName(fallback: string): Promise<string> {
@@ -381,7 +499,9 @@ async function askAgentName(fallback: string): Promise<string> {
defaultValue: fallback,
}),
);
return (answer as string).trim() || fallback;
const value = (answer as string).trim() || fallback;
setupLog.userInput('agent_name', value);
return value;
}
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
@@ -394,9 +514,94 @@ async function askChannelChoice(): Promise<'telegram' | 'skip'> {
],
}),
);
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')}`;
@@ -414,6 +619,7 @@ function printIntro(): void {
async function main(): Promise<void> {
printIntro();
initProgressionLog();
const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '')
@@ -479,22 +685,28 @@ async function main(): Promise<void> {
}
if (!skip.has('auth')) {
failingStep = 'auth';
if (anthropicSecretExists()) {
p.log.success('OneCLI already has an Anthropic secret — skipping.');
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
} else {
p.log.step('Registering your Anthropic credential…');
console.log(
k.dim(' (browser sign-in or paste a token/key — this part is interactive)'),
);
console.log();
const start = Date.now();
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
const durationMs = Date.now() - start;
console.log();
if (code !== 0) {
setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code });
fail(
'Anthropic credential registration failed or was aborted.',
'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.',
);
}
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'register-claude-token.sh' });
p.log.success('Anthropic credential registered with OneCLI.');
}
}
@@ -558,17 +770,28 @@ async function main(): Promise<void> {
if (!skip.has('channel')) {
const choice = await askChannelChoice();
if (choice === 'telegram') {
p.log.step('Installing the Telegram adapter and collecting your bot token…');
console.log();
const installCode = await runInheritScript('bash', ['setup/add-telegram.sh']);
console.log();
if (installCode !== 0) {
const token = await collectTelegramToken();
const botUsername = await validateTelegramToken(token);
const install = await runQuietChild(
'telegram-install',
'bash',
['setup/add-telegram.sh'],
{
running: `Installing Telegram adapter and wiring @${botUsername}`,
done: `Telegram adapter ready.`,
},
{
env: { TELEGRAM_BOT_TOKEN: token },
extraFields: { BOT_USERNAME: botUsername },
},
);
if (!install.ok) {
fail(
'Telegram install failed.',
'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.',
'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.',
);
}
p.log.success('Telegram adapter installed.');
const pair = await runPairTelegram();
if (!pair.ok) {
@@ -592,6 +815,7 @@ async function main(): Promise<void> {
(await askAgentName(DEFAULT_AGENT_NAME));
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
@@ -605,6 +829,9 @@ async function main(): Promise<void> {
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(
@@ -652,9 +879,43 @@ async function main(): Promise<void> {
`${k.cyan('Open Claude Code:')} claude`,
].join('\n');
p.note(nextSteps, 'Next steps');
setupLog.complete(Date.now() - RUN_START);
p.outro(k.green('Setup complete.'));
}
/**
* Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes
* the bootstrap entry before we even boot. If someone runs `pnpm run
* setup:auto` directly, start a fresh progression log here so we don't
* append to a stale one from a previous run.
*/
function initProgressionLog(): void {
if (process.env.NANOCLAW_BOOTSTRAPPED === '1') return;
let commit = '';
try {
commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
encoding: 'utf-8',
}).stdout.trim();
} catch {
// git not available or not a repo — skip
}
let branch = '';
try {
branch = spawnSync('git', ['branch', '--show-current'], {
encoding: 'utf-8',
}).stdout.trim();
} catch {
// skip
}
setupLog.reset({
invocation: 'setup:auto (standalone)',
user: process.env.USER ?? 'unknown',
cwd: process.cwd(),
branch: branch || 'unknown',
commit: commit || 'unknown',
});
}
main().catch((err) => {
p.log.error(err instanceof Error ? err.message : String(err));
p.cancel('Setup aborted.');

130
setup/logs.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Three-level setup logging primitives. See docs/setup-flow.md for the
* contract and design rationale.
*
* Level 1: clack UI in setup/auto.ts (not here)
* Level 2: logs/setup.log — structured, append-only progression log
* Level 3: logs/setup-steps/NN-name.log — raw stdout+stderr per step
*
* Usage from auto.ts:
*
* import * as setupLog from './logs.js';
*
* const rawLog = setupLog.stepRawLog('container');
* const { ok, durationMs, terminal } =
* await spawnIntoRawLog('...', rawLog);
* setupLog.step('container', ok ? 'success' : 'failed', durationMs,
* { RUNTIME: 'docker', BUILD_OK: terminal.fields.BUILD_OK },
* rawLog);
*
* nanoclaw.sh emits the bootstrap entry directly via a bash helper so
* the format stays consistent without needing IPC between bash and tsx.
*/
import fs from 'fs';
import path from 'path';
const LOGS_DIR = 'logs';
const STEPS_DIR = path.join(LOGS_DIR, 'setup-steps');
const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log');
export const progressLogPath = PROGRESS_LOG;
export const stepsDir = STEPS_DIR;
/** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */
export function reset(meta: Record<string, string>): void {
if (fs.existsSync(STEPS_DIR)) {
fs.rmSync(STEPS_DIR, { recursive: true, force: true });
}
fs.mkdirSync(STEPS_DIR, { recursive: true });
if (fs.existsSync(PROGRESS_LOG)) fs.unlinkSync(PROGRESS_LOG);
header(meta);
}
/** Append a run-start header to the progression log. Idempotent: creates the file if missing. */
export function header(meta: Record<string, string>): void {
fs.mkdirSync(LOGS_DIR, { recursive: true });
const ts = new Date().toISOString();
const lines = [`## ${ts} · setup:auto started`];
for (const [k, v] of Object.entries(meta)) {
lines.push(` ${k}: ${v}`);
}
lines.push('');
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
}
/** Append one step entry to the progression log. */
export function step(
name: string,
status: 'success' | 'skipped' | 'failed' | 'aborted' | 'interactive',
durationMs: number,
fields: Record<string, string | number | boolean | undefined>,
rawRel?: string,
): void {
fs.mkdirSync(LOGS_DIR, { recursive: true });
const ts = new Date().toISOString();
const dur = formatDuration(durationMs);
const lines = [`=== [${ts}] ${name} [${dur}] → ${status} ===`];
for (const [k, v] of Object.entries(fields)) {
if (v === undefined || v === null || v === '') continue;
lines.push(` ${k.toLowerCase()}: ${String(v)}`);
}
if (rawRel) lines.push(` raw: ${rawRel}`);
lines.push('');
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
}
/** A user answered a prompt. Logs as its own entry because the setup path depends on it. */
export function userInput(key: string, value: string): void {
fs.mkdirSync(LOGS_DIR, { recursive: true });
const ts = new Date().toISOString();
fs.appendFileSync(
PROGRESS_LOG,
`=== [${ts}] user-input → ${key} ===\n value: ${value}\n\n`,
);
}
/** Append the success footer. */
export function complete(totalMs: number): void {
const ts = new Date().toISOString();
fs.appendFileSync(
PROGRESS_LOG,
`## ${ts} · completed (total ${formatDurationTotal(totalMs)})\n`,
);
}
/** Append the failure footer. Keep error short — full context lives in the failing step's raw log. */
export function abort(stepName: string, error: string): void {
const ts = new Date().toISOString();
fs.appendFileSync(
PROGRESS_LOG,
`## ${ts} · aborted at ${stepName} (${error})\n`,
);
}
/**
* Return the next raw-log path for a given step name. Numbering is derived
* from the count of existing NN-*.log files in STEPS_DIR, so bootstrap's
* pre-existing 01-bootstrap.log (written by nanoclaw.sh before this module
* is loaded) counts toward the sequence.
*/
export function stepRawLog(name: string): string {
fs.mkdirSync(STEPS_DIR, { recursive: true });
const existing = fs
.readdirSync(STEPS_DIR)
.filter((n) => /^\d+-.+\.log$/.test(n));
const nextIdx = existing.length + 1;
const num = String(nextIdx).padStart(2, '0');
const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
return path.join(STEPS_DIR, `${num}-${safeName}.log`);
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatDurationTotal(ms: number): string {
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
}