From 5269edada4ddcb23c27ac201c7b5eb2c8a60fdc5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:02:13 +0300 Subject: [PATCH] feat(setup): three-level output (clack UI / progression log / raw per-step) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/setup-flow.md | 226 ++++++++++++++++++++++++++ nanoclaw.sh | 162 +++++++++++++++++-- setup.sh | 12 +- setup/add-telegram.sh | 171 ++++++++++---------- setup/auto.ts | 359 ++++++++++++++++++++++++++++++++++++------ setup/logs.ts | 130 +++++++++++++++ 6 files changed, 909 insertions(+), 151 deletions(-) create mode 100644 docs/setup-flow.md create mode 100644 setup/logs.ts diff --git a/docs/setup-flow.md b/docs/setup-flow.md new file mode 100644 index 000000000..800411cf3 --- /dev/null +++ b/docs/setup-flow.md @@ -0,0 +1,226 @@ +# Setup flow + +This document is the contract for NanoClaw's end-to-end scripted setup +(`bash nanoclaw.sh` → `pnpm run setup:auto`). Read it before adding a new +step, fixing a regression, or changing how output is rendered. + +## The three output levels + +Every setup step produces output at **three distinct levels**. They have +different audiences, go to different places, and are formatted differently. +Don't conflate them. + +| Level | Audience | Destination | Format | +|---|---|---|---| +| 1. User-facing | The operator running setup | Terminal (via clack) | Branded, concise, informational — "product content" | +| 2. Progression | Future debuggers, AI agents reviewing a failed run, release support | `logs/setup.log` (one file, append-only) | Structured per-step blocks, linear chronology, human + machine readable | +| 3. Raw | Whoever is deep-debugging a specific step | `logs/setup-steps/NN-step-name.log` (one file per step) | Full raw child stdout + stderr, verbatim | + +Think of it as: the user sees a **summary**, the progression log is an +**index with key facts**, the raw logs are the **evidence**. + +### Level 1: user-facing (clack) + +Rendered by `setup/auto.ts` via `@clack/prompts`. This is our *product +surface* for setup — every line should read as if we designed it for a +stranger on day one. + +- Clack spinners for in-progress work. Show elapsed time. +- `p.log.success` / `p.log.step` / `p.log.warn` for permanent status + markers. +- `p.note` for multi-line information (pairing code, next steps). +- `p.text` / `p.select` / `p.password` for prompts. +- Brand palette: `brand()` / `brandBold()` / `brandChip()` helpers in + `setup/auto.ts`. Truecolor when the terminal supports it, 16-color + cyan fallback otherwise, plain text when piped / `NO_COLOR`. + +Rules: +- **No discontinuity.** Every sub-step belongs to the same visual flow. + The only exception is Anthropic credential registration (see below). +- **No raw child output.** Never `stdio: 'inherit'` a child whose output + wasn't written by us. Capture it and show it on failure only. +- **No debug-style prefixes** (`[add-telegram] …`, `INFO …`, timestamps). + Those belong in levels 2 and 3. +- **No emoji** unless the clack glyph requires it. + +### Level 2: progression log + +`logs/setup.log` — one file per setup run, append-only, cumulative across +a multi-run install (if a run fails midway and is re-attempted, the new +entries append). It's the thing you'd ask an operator to paste when they +report a setup bug, and the thing an AI agent would read to understand +what happened. + +Entry format: + +``` +=== [2026-04-22T22:14:12Z] bootstrap [45.1s] → success === + platform: linux + is_wsl: false + node_version: 22.22.2 + deps_ok: true + native_ok: true + raw: logs/setup-steps/01-bootstrap.log + +=== [2026-04-22T22:14:57Z] environment [2.3s] → success === + docker: running + apple_container: not_found + raw: logs/setup-steps/02-environment.log + +=== [2026-04-22T22:15:00Z] container [92.4s] → success === + runtime: docker + image: nanoclaw-agent:latest + build_ok: true + raw: logs/setup-steps/03-container.log +``` + +Design constraints: +- Start-time timestamp (UTC, ISO-8601) on the opening line so a `grep` + gives you the sequence. +- Duration in seconds with one decimal — fast steps read as "0.5s", not + "0ms". +- Status is one of: `success`, `skipped`, `failed`, `aborted`. +- Fields are step-specific but **must** be short scalar values. No JSON, + no multi-line. If a value is long, put it in the raw log and reference + it. +- Always emit a `raw:` pointer, even on success — makes debugging the + second failure easier. +- **User choices** are their own entries, not nested inside a step: + + ``` + === [2026-04-22T22:17:44Z] user-input → display_name === + value: gav + + === [2026-04-22T22:17:51Z] user-input → channel_choice === + value: telegram + ``` + + These matter because the path through the setup flow depends on them. + +The log opens with a header block identifying the run, and closes with +a completion block: + +``` +## 2026-04-22T22:14:12Z · setup:auto started + user: exedev + cwd: /home/exedev/nanoclaw + branch: branded-setup + commit: 6e0d742 + +… (step entries) … + +## 2026-04-22T22:18:54Z · completed (total 4m42s) +``` + +On failure the completion block names the failing step and its error: + +``` +## 2026-04-22T22:16:40Z · aborted at container (err=cache_miss) +``` + +### Level 3: raw per-step logs + +`logs/setup-steps/NN-step-name.log` — one file per step, numbered in +execution order (zero-padded 2-digit prefix for natural sorting). Full +verbatim stdout + stderr from the child process. Truncated and rewritten +on each run (not appended). + +Contents are whatever the step emits: apt output, docker build layers, +pnpm install spam, `curl` bodies, etc. This is the evidence plane — +"what did the shell actually see?" Nothing is filtered. + +## Contract for a new step + +When you add a step (either a TS step in `setup/.ts` or a bash +installer invoked from `auto.ts`), it must: + +1. **Receive a raw-log path** from the caller. Write all stdout + stderr + there. Don't write to the terminal directly. +2. **Emit a single terminal status block** at the end, containing + `STATUS: success|skipped|failed` and any step-specific fields: + + ``` + === NANOCLAW SETUP: STEP_NAME === + STATUS: success + KEY: value + KEY: value + === END === + ``` + + Field names are `UPPER_SNAKE_CASE`. Values are short scalars. + +3. If it's a long-running step, optionally emit **sub-status blocks** + mid-stream. `auto.ts` parses them live and can render intermediate + UI (as `pair-telegram` does with `PAIR_TELEGRAM_CODE` / + `PAIR_TELEGRAM_ATTEMPT`). + +4. **Exit non-zero** on hard failure so `auto.ts` can distinguish + "step ran to completion and reported failed" from "step crashed". + +The driver handles the rest: spinner in level 1, structured append to +level 2, raw capture to level 3. + +## The Anthropic exception + +Anthropic credential registration (`setup/register-claude-token.sh`) is +the **one** permitted break in the visual flow. Why: + +- `claude setup-token` opens a browser, runs its own OAuth prompt, and + prints the token. It owns the TTY via `script(1)`. +- We don't want to re-implement the OAuth device flow ourselves. +- We don't want to intercept / mirror the token (it appears in the + user's terminal already — mirroring it adds attack surface). + +So during this step: +- The clack flow explicitly pauses (a `p.log.step` marker says "this + part is interactive, you're handing off to Anthropic"). +- The child inherits stdio fully. +- When control returns, clack resumes on the next line with a success + marker. + +The level-2 log still gets an entry (`auth [interactive] → success` +with the method — subscription / oauth-token / api-key). Level-3 captures +are optional here; mirroring `script -q` output is tricky and the risk of +leaking the token to disk outweighs the debugging value. + +## File reference + +| File | Role | +|---|---| +| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. | +| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). | +| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. | +| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. | +| `setup/.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. | +| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. | +| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. | +| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. | + +## Common pitfalls + +- **Printing debug output from inside a step.** Tempting during + development; forbidden in checked-in code. All runtime messaging goes + through status blocks (level 2) or raw log writes (level 3). +- **Adding a `console.log` that "just this once" goes to the terminal.** + It breaks the clack flow — the spinner line gets torn. Use + `log.info` / `log.error` from `src/log.ts` (writes to the raw log) + instead. +- **`stdio: 'inherit'` for a non-exception child.** See Anthropic above. + Anything else needs `pipe` + explicit capture. +- **Tee-ing to stderr.** Clack's spinner owns the terminal during a step. + Even stderr writes tear the frame. Pipe everything, then choose what + to surface. +- **UTF-8 in bash `$VAR…` positions.** Bash's lexer can pull the first + byte of a multi-byte character into the variable name and trip + `set -u`. Always brace: `${VAR}…`. + +## Future work (not yet implemented) + +- **Progression log rotation.** Today's implementation truncates on each + run. Future: roll prior runs to `logs/setup.log.1`, `.2`, etc. +- **Raw log rotation for multi-run installs.** Currently each run + overwrites. Fine for now; revisit if support needs to compare + successive attempts. +- **Structured output from `register-claude-token.sh`.** The interactive + step emits no machine-readable status today. Future could add a + post-interaction status block with the method used. diff --git a/nanoclaw.sh b/nanoclaw.sh index 2dc0f048e..17df82ce1 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -2,12 +2,15 @@ # # NanoClaw — scripted end-to-end install. # -# Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module -# verify), then `pnpm run setup:auto` (environment → container → onecli → -# auth → mounts → service → cli-agent → channel → verify). +# Phase 1: bootstrap (Node + pnpm + native module verify). Runs bash-side +# since tsx isn't available until pnpm install completes. +# Phase 2: setup:auto (all remaining steps under clack). # -# Everything that can be scripted runs unattended; the one interactive pause -# is the auth step (browser sign-in or paste token/API key). +# Both phases obey the same three-level output contract (see +# docs/setup-flow.md): +# 1. User-facing — concise status line with elapsed time +# 2. Progression log — logs/setup.log (header + one entry per phase/step) +# 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output) # # Config via env — passed through unchanged: # NANOCLAW_SKIP comma-separated setup:auto step names to skip @@ -19,28 +22,163 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$PROJECT_ROOT" +LOGS_DIR="$PROJECT_ROOT/logs" +STEPS_DIR="$LOGS_DIR/setup-steps" +PROGRESS_LOG="$LOGS_DIR/setup.log" + +# ─── log helpers ──────────────────────────────────────────────────────── + +ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +write_header() { + local ts + ts=$(ts_utc) + local branch commit + branch=$(git branch --show-current 2>/dev/null || echo unknown) + commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) + { + echo "## ${ts} · setup:auto started" + echo " invocation: nanoclaw.sh" + echo " user: $(whoami)" + echo " cwd: ${PROJECT_ROOT}" + echo " branch: ${branch}" + echo " commit: ${commit}" + echo "" + } > "$PROGRESS_LOG" +} + +# grep_field FIELD FILE — first value of FIELD: from a status block. +grep_field() { + grep "^$1:" "$2" 2>/dev/null | head -1 | sed "s/^$1: *//" || true +} + +write_bootstrap_entry() { + local status=$1 dur=$2 raw=$3 + local ts + ts=$(ts_utc) + local platform is_wsl node_version deps_ok native_ok has_build_tools + platform=$(grep_field PLATFORM "$raw") + is_wsl=$(grep_field IS_WSL "$raw") + node_version=$(grep_field NODE_VERSION "$raw" | head -1) + deps_ok=$(grep_field DEPS_OK "$raw") + native_ok=$(grep_field NATIVE_OK "$raw") + has_build_tools=$(grep_field HAS_BUILD_TOOLS "$raw") + { + echo "=== [${ts}] bootstrap [${dur}s] → ${status} ===" + [ -n "$platform" ] && echo " platform: ${platform}" + [ -n "$is_wsl" ] && echo " is_wsl: ${is_wsl}" + [ -n "$node_version" ] && echo " node_version: ${node_version}" + [ -n "$deps_ok" ] && echo " deps_ok: ${deps_ok}" + [ -n "$native_ok" ] && echo " native_ok: ${native_ok}" + [ -n "$has_build_tools" ] && echo " has_build_tools: ${has_build_tools}" + # Emit the raw path relative to PROJECT_ROOT so the progression log + # is portable and matches the TS-side format (logs/setup-steps/NN-…). + echo " raw: ${raw#${PROJECT_ROOT}/}" + echo "" + } >> "$PROGRESS_LOG" +} + +write_abort_entry() { + local step=$1 error=$2 + local ts + ts=$(ts_utc) + echo "## ${ts} · aborted at ${step} (${error})" >> "$PROGRESS_LOG" +} + +# ─── bash-side "clack-alike" status line ──────────────────────────────── + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; } +red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } + +spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; } +spinner_update() { clear_line; printf '%s %s… %s' "$(gray '◒')" "$1" "$(dim "(${2}s)")"; } +spinner_success() { clear_line; printf '%s %s %s\n' "$(gray '◇')" "$1" "$(dim "(${2}s)")"; } +spinner_failure() { clear_line; printf '%s %s %s\n' "$(red '✗')" "$1" "$(dim "(${2}s)")"; } + +# ─── fresh-run setup ──────────────────────────────────────────────────── + +rm -rf "$STEPS_DIR" +rm -f "$PROGRESS_LOG" +mkdir -p "$STEPS_DIR" "$LOGS_DIR" +write_header + cat <<'EOF' ═══════════════════════════════════════════════════════════════ NanoClaw scripted setup ═══════════════════════════════════════════════════════════════ -Phase 1: bootstrap (Node + pnpm + native modules) +Phase 1 · bootstrap EOF -if ! bash setup.sh; then +# ─── phase 1: bootstrap ───────────────────────────────────────────────── + +BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" +BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules" +BOOTSTRAP_START=$(date +%s) + +spinner_start "$BOOTSTRAP_LABEL" + +# Run in the background so we can tick elapsed time. Capture exit code via +# a tmpfile (subshell $? is lost after the while loop finishes). +BOOTSTRAP_EXIT_FILE=$(mktemp -t nanoclaw-bootstrap-exit.XXXXXX) +( + # setup.sh's legacy `log()` writes to a file; point it at the raw log + # so its verbose entries land alongside the stdout we're capturing. + export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW" + if bash setup.sh > "$BOOTSTRAP_RAW" 2>&1; then + echo 0 > "$BOOTSTRAP_EXIT_FILE" + else + echo $? > "$BOOTSTRAP_EXIT_FILE" + fi +) & +BOOTSTRAP_PID=$! + +while kill -0 "$BOOTSTRAP_PID" 2>/dev/null; do + sleep 1 + if kill -0 "$BOOTSTRAP_PID" 2>/dev/null; then + spinner_update "$BOOTSTRAP_LABEL" "$(( $(date +%s) - BOOTSTRAP_START ))" + fi +done +# `wait` surfaces the child's exit code; we've already captured it. +wait "$BOOTSTRAP_PID" 2>/dev/null || true + +BOOTSTRAP_RC=$(cat "$BOOTSTRAP_EXIT_FILE") +rm -f "$BOOTSTRAP_EXIT_FILE" +BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) + +if [ "$BOOTSTRAP_RC" -eq 0 ]; then + spinner_success "Bootstrap complete" "$BOOTSTRAP_DUR" + write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" +else + spinner_failure "Bootstrap failed" "$BOOTSTRAP_DUR" + write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" + write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" + echo - echo "[nanoclaw.sh] Bootstrap failed. Inspect logs/setup.log and retry." >&2 + echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" + tail -40 "$BOOTSTRAP_RAW" + echo + echo "Full raw log: $BOOTSTRAP_RAW" + echo "Progression: $PROGRESS_LOG" exit 1 fi +echo cat <<'EOF' - -═══════════════════════════════════════════════════════════════ - Phase 2: setup:auto -═══════════════════════════════════════════════════════════════ +Phase 2 · setup:auto EOF +# ─── phase 2: clack driver ────────────────────────────────────────────── + +# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts that the progression log has +# already been initialized (header + bootstrap entry), so it should append +# rather than wipe. +export NANOCLAW_BOOTSTRAPPED=1 + # exec so signals (Ctrl-C) propagate directly to the child. exec pnpm run setup:auto diff --git a/setup.sh b/setup.sh index e163df851..ae5da2789 100755 --- a/setup.sh +++ b/setup.sh @@ -6,9 +6,17 @@ set -euo pipefail # This is the only bash script in the setup flow. PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOG_FILE="$PROJECT_ROOT/logs/setup.log" -mkdir -p "$PROJECT_ROOT/logs" +# Where verbose bootstrap logs go. nanoclaw.sh captures setup.sh's stdout to +# the per-step raw log, but legacy code in this script + install-node.sh +# also calls `log` which writes to a file. Route those to the raw log so +# they don't contaminate the progression log (logs/setup.log). +# Default: write to the raw bootstrap log if nanoclaw.sh pointed us there, +# else fall back to a dedicated bootstrap log (keeps standalone `bash +# setup.sh` invocations working). +LOG_FILE="${NANOCLAW_BOOTSTRAP_LOG:-${PROJECT_ROOT}/logs/bootstrap.log}" + +mkdir -p "$(dirname "$LOG_FILE")" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; } diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 4d540af5d..5036bd4da 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -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 :)" + 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: :) - -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 &2 - exit 1 - fi - - # Telegram bot tokens: :<35+ base64url-ish chars>. - if [[ ! "$TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then - echo "[add-telegram] Token format looks wrong (expected :). 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 diff --git a/setup/auto.ts b/setup/auto.ts index 482fcea0d..c16b6e50e 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -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 { 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 { - return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {})); +): Promise { + failingStep = stepName; + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnStep(stepName, extra, () => {}, rawLog), + ); + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; } -/** Run an arbitrary child under a spinner, 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; + /** Environment overrides to pass to the child process. */ + env?: NodeJS.ProcessEnv; + }, +): Promise<{ + ok: boolean; + exitCode: number; + transcript: string; + terminal: Block | null; + rawLog: string; + durationMs: number; +}> { + failingStep = logName; + const rawLog = setupLog.stepRawLog(logName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnQuiet(cmd, args, rawLog, opts?.env), + ); + const durationMs = Date.now() - start; + + const blockFields = summariseTerminalFields(result.terminal); + const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; + const rawStatus = result.terminal?.fields.STATUS; + const status: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + setupLog.step(logName, status, durationMs, fields, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Turn a step's terminal-block fields into a concise progression-log entry. */ +function writeStepEntry( + stepName: string, + result: StepResult, + durationMs: number, + rawLog: string, +): void { + const rawStatus = result.terminal?.fields.STATUS; + const logStatus: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + const fields = summariseTerminalFields(result.terminal); + setupLog.step(stepName, logStatus, durationMs, fields, rawLog); +} + +/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ +function summariseTerminalFields(block: Block | null): Record { + if (!block) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(block.fields)) { + if (k === 'STATUS' || k === 'LOG') continue; + if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log + out[k] = v; + } + return out; } async function runUnderSpinner< @@ -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(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 { +async function runPairTelegram(): Promise { + failingStep = 'pair-telegram'; + const rawLog = setupLog.stepRawLog('pair-telegram'); + const start = Date.now(); const s = p.spinner(); s.start('Creating pairing code…'); let spinnerActive = true; @@ -329,29 +437,35 @@ async function runPairTelegram(): Promise { } }; - 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 { 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 { @@ -370,7 +486,9 @@ async function askDisplayName(fallback: string): Promise { 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 { @@ -381,7 +499,9 @@ async function askAgentName(fallback: string): Promise { 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 { + p.note( + [ + '1. Open Telegram and message @BotFather', + '2. Send: /newbot', + '3. Follow the prompts (name + username ending in "bot")', + '4. Copy the token it gives you (format: :)', + '', + k.dim('Optional, but recommended for groups:'), + k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ].join('\n'), + 'Create a Telegram bot', + ); + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + if (!v || !v.trim()) return 'Token is required'; + if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return 'Format looks wrong — expected :'; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'telegram_token', + `${token.slice(0, 12)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateTelegramToken(token: string): Promise { + failingStep = 'telegram-validate'; + const s = p.spinner(); + const start = Date.now(); + s.start('Validating token with Telegram…'); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const data = (await res.json()) as { + ok?: boolean; + result?: { username?: string; id?: number }; + description?: string; + }; + const elapsed = Math.round((Date.now() - start) / 1000); + if (data.ok && data.result?.username) { + const username = data.result.username; + s.stop(`Bot is @${username}. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step( + 'telegram-validate', + 'success', + Date.now() - start, + { BOT_USERNAME: username, BOT_ID: data.result.id ?? '' }, + ); + return username; + } + const reason = data.description ?? 'token rejected by Telegram'; + s.stop(`Telegram rejected the token: ${reason}`, 1); + setupLog.step( + 'telegram-validate', + 'failed', + Date.now() - start, + { ERROR: reason }, + ); + fail( + 'Telegram rejected the token.', + 'Double-check the token (copy it again from @BotFather) and retry.', + ); + } catch (err) { + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`Could not reach Telegram. ${k.dim(`(${elapsed}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'Telegram API unreachable.', + 'Check your network connection and retry.', + ); + } +} + function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; @@ -414,6 +619,7 @@ function printIntro(): void { async function main(): Promise { printIntro(); + initProgressionLog(); const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') @@ -479,22 +685,28 @@ async function main(): Promise { } if (!skip.has('auth')) { + failingStep = 'auth'; if (anthropicSecretExists()) { p.log.success('OneCLI already has an Anthropic secret — skipping.'); + setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); } 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 { 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 { (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 { 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 { `${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.'); diff --git a/setup/logs.ts b/setup/logs.ts new file mode 100644 index 000000000..127f9692b --- /dev/null +++ b/setup/logs.ts @@ -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): 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): 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, + 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`; +}