mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
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:
@@ -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/<name>.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/<step>.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.
|
||||
+150
-12
@@ -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
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
|
||||
+83
-88
@@ -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
|
||||
|
||||
+310
-49
@@ -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
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user