mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge pull request #1908 from qwibitai/setup-auto
feat(setup): scripted branded setup flow (nanoclaw.sh)
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.
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# NanoClaw — end-to-end setup entry point.
|
||||
#
|
||||
# Runs two parts from the user's perspective as one continuous flow:
|
||||
# - bash-side: install the basics (Node + pnpm + native modules) under a
|
||||
# bash-rendered clack-alike spinner. Can't use setup/auto.ts here since
|
||||
# tsx isn't available until pnpm install completes.
|
||||
# - hand off to `pnpm run setup:auto`, which renders the rest with
|
||||
# @clack/prompts. The wordmark is printed once here so setup:auto can
|
||||
# skip it and the flow reads as a single sequence.
|
||||
#
|
||||
# Obeys the 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 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
|
||||
# SECRET_NAME OneCLI secret name (default: Anthropic)
|
||||
# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com)
|
||||
|
||||
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"; }
|
||||
bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
# brand cyan (≈ #2BB7CE) — truecolor when supported, 16-color cyan fallback.
|
||||
brand_bold() {
|
||||
if use_ansi; then
|
||||
if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then
|
||||
printf '\033[1;38;2;43;183;206m%s\033[0m' "$1"
|
||||
else
|
||||
printf '\033[1;36m%s\033[0m' "$1"
|
||||
fi
|
||||
else
|
||||
printf '%s' "$1"
|
||||
fi
|
||||
}
|
||||
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
|
||||
|
||||
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
||||
# and skip printing these again, so the flow stays visually continuous.
|
||||
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
||||
|
||||
# ─── first step: install the basics (Node + pnpm + native modules) ─────
|
||||
|
||||
BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
||||
BOOTSTRAP_LABEL="Installing the basics"
|
||||
BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
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 "Basics installed" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}"
|
||||
|
||||
echo
|
||||
echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')"
|
||||
tail -40 "$BOOTSTRAP_RAW"
|
||||
echo
|
||||
echo "$(dim "Full raw log: $BOOTSTRAP_RAW")"
|
||||
echo "$(dim "Progression: $PROGRESS_LOG")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── hand off to setup:auto ────────────────────────────────────────────
|
||||
|
||||
# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts to skip the wordmark (we
|
||||
# already printed it) and to append to the progression log rather than
|
||||
# wipe it.
|
||||
export NANOCLAW_BOOTSTRAPPED=1
|
||||
|
||||
# --silent suppresses pnpm's `> nanoclaw@1.2.52 setup:auto / > tsx setup/auto.ts`
|
||||
# preamble so the flow continues visually from "Basics installed" straight
|
||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||
exec pnpm --silent run setup:auto
|
||||
+5
-1
@@ -15,6 +15,7 @@
|
||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||
"prepare": "husky",
|
||||
"setup": "tsx setup/index.ts",
|
||||
"setup:auto": "tsx setup/auto.ts",
|
||||
"chat": "tsx scripts/chat.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"lint": "eslint src/",
|
||||
@@ -23,10 +24,13 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chat-adapter/telegram": "4.26.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"cron-parser": "5.5.0"
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
|
||||
Generated
+76
@@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@chat-adapter/telegram':
|
||||
specifier: 4.26.0
|
||||
version: 4.26.0
|
||||
'@clack/prompts':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@onecli-sh/sdk':
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.1
|
||||
@@ -20,6 +26,9 @@ importers:
|
||||
cron-parser:
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
kleur:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
@@ -60,6 +69,18 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@chat-adapter/shared@4.26.0':
|
||||
resolution: {integrity: sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==}
|
||||
|
||||
'@chat-adapter/telegram@4.26.0':
|
||||
resolution: {integrity: sha512-PE2HoCQ4648VNKZTuHFanQNoYzM/niNoSbDyYlPq6VOoB5qsoo1ctR8TERyl1EfPBNexWZpSWYrrnQPr15LUfA==}
|
||||
|
||||
'@clack/core@1.2.0':
|
||||
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
|
||||
|
||||
'@clack/prompts@1.2.0':
|
||||
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
@@ -748,6 +769,15 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-string-truncated-width@1.2.1:
|
||||
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
|
||||
|
||||
fast-string-width@1.1.0:
|
||||
resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==}
|
||||
|
||||
fast-wrap-ansi@0.1.6:
|
||||
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -866,6 +896,10 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1239,6 +1273,9 @@ packages:
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1462,6 +1499,31 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@chat-adapter/shared@4.26.0':
|
||||
dependencies:
|
||||
chat: 4.26.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@chat-adapter/telegram@4.26.0':
|
||||
dependencies:
|
||||
'@chat-adapter/shared': 4.26.0
|
||||
chat: 4.26.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@clack/core@1.2.0':
|
||||
dependencies:
|
||||
fast-wrap-ansi: 0.1.6
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@1.2.0':
|
||||
dependencies:
|
||||
'@clack/core': 1.2.0
|
||||
fast-string-width: 1.1.0
|
||||
fast-wrap-ansi: 0.1.6
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -2105,6 +2167,16 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-string-truncated-width@1.2.1: {}
|
||||
|
||||
fast-string-width@1.1.0:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 1.2.1
|
||||
|
||||
fast-wrap-ansi@0.1.6:
|
||||
dependencies:
|
||||
fast-string-width: 1.1.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -2191,6 +2263,8 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -2735,6 +2809,8 @@ snapshots:
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
+22
-52
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* Init the first (or Nth) NanoClaw v2 agent for a DM channel.
|
||||
*
|
||||
* Wires a real DM channel (discord, telegram, etc.) to a new agent group
|
||||
* (and the local CLI channel as a convenience bonus), then hands a welcome
|
||||
* message to the running service via its CLI socket. The service routes
|
||||
* that message into the DM session, which wakes the container synchronously —
|
||||
* the agent processes the welcome and DMs the operator through the normal
|
||||
* delivery path.
|
||||
* Wires a real DM channel (discord, telegram, etc.) to a new agent group,
|
||||
* then hands a welcome message to the running service via the CLI socket
|
||||
* (admin transport). The service routes that message into the DM session,
|
||||
* which wakes the container synchronously — the agent processes the welcome
|
||||
* and DMs the operator through the normal delivery path.
|
||||
*
|
||||
* For the CLI-only scratch agent used during `/new-setup`, see
|
||||
* `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run
|
||||
* through here.
|
||||
* CLI channel wiring is handled separately by `scripts/init-cli-agent.ts`.
|
||||
*
|
||||
* Creates/reuses: user, owner grant (if none), agent group + filesystem,
|
||||
* messaging group(s), wiring.
|
||||
@@ -27,8 +24,7 @@
|
||||
* --platform-id discord:@me:1491573333382523708 \
|
||||
* --display-name "Gavriel" \
|
||||
* [--agent-name "Andy"] \
|
||||
* [--welcome "System instruction: ..."] \
|
||||
* [--no-cli-bonus]
|
||||
* [--welcome "System instruction: ..."]
|
||||
*
|
||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
||||
@@ -53,7 +49,6 @@ import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
interface Args {
|
||||
noCliBonus: boolean;
|
||||
channel: string;
|
||||
userId: string;
|
||||
platformId: string;
|
||||
@@ -65,18 +60,12 @@ interface Args {
|
||||
const DEFAULT_WELCOME =
|
||||
'System instruction: run /welcome to introduce yourself to the user on this new channel.';
|
||||
|
||||
const CLI_CHANNEL = 'cli';
|
||||
const CLI_PLATFORM_ID = 'local';
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const out: Partial<Args> = { noCliBonus: false };
|
||||
const out: Partial<Args> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const val = argv[i + 1];
|
||||
switch (key) {
|
||||
case '--no-cli-bonus':
|
||||
out.noCliBonus = true;
|
||||
break;
|
||||
case '--channel':
|
||||
out.channel = (val ?? '').toLowerCase();
|
||||
i++;
|
||||
@@ -115,7 +104,6 @@ function parseArgs(argv: string[]): Args {
|
||||
}
|
||||
|
||||
return {
|
||||
noCliBonus: out.noCliBonus ?? false,
|
||||
channel: out.channel!,
|
||||
userId: out.userId!,
|
||||
platformId: out.platformId!,
|
||||
@@ -137,24 +125,6 @@ function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function ensureCliMessagingGroup(now: string): MessagingGroup {
|
||||
let cliMg = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
|
||||
if (cliMg) return cliMg;
|
||||
|
||||
cliMg = {
|
||||
id: generateId('mg'),
|
||||
channel_type: CLI_CHANNEL,
|
||||
platform_id: CLI_PLATFORM_ID,
|
||||
name: 'Local CLI',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now,
|
||||
};
|
||||
createMessagingGroup(cliMg);
|
||||
console.log(`Created CLI messaging group: ${cliMg.id}`);
|
||||
return cliMg;
|
||||
}
|
||||
|
||||
function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
|
||||
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (existing) {
|
||||
@@ -252,29 +222,23 @@ async function main(): Promise<void> {
|
||||
console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`);
|
||||
}
|
||||
|
||||
// 4. Wire DM (auto-creates companion agent_destinations row) and,
|
||||
// unless suppressed, also wire the CLI channel so `pnpm run chat` works
|
||||
// against the new agent immediately. `/new-setup-2` sets --no-cli-bonus
|
||||
// so the scratch CLI agent from `/new-setup` keeps owning CLI routing.
|
||||
// 4. Wire DM messaging group to the agent.
|
||||
wireIfMissing(dmMg, ag, now, 'dm');
|
||||
if (!args.noCliBonus) {
|
||||
const cliMg = ensureCliMessagingGroup(now);
|
||||
wireIfMissing(cliMg, ag, now, 'cli-bonus');
|
||||
}
|
||||
|
||||
// 5. Welcome delivery over the CLI socket. Router picks up the line,
|
||||
// writes the message into the DM session's inbound.db, and wakes the
|
||||
// container synchronously — no sweep wait.
|
||||
await sendWelcomeViaCliSocket(dmMg, args.welcome);
|
||||
// container synchronously — no sweep wait. The paired user's identity is
|
||||
// passed so the sender resolver sees the real owner, not cli:local.
|
||||
await sendWelcomeViaCliSocket(dmMg, args.welcome, {
|
||||
senderId: userId,
|
||||
sender: args.displayName,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('Init complete.');
|
||||
console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`);
|
||||
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
|
||||
console.log(` channel: ${args.channel} ${dmMg.platform_id}`);
|
||||
if (!args.noCliBonus) {
|
||||
console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``);
|
||||
}
|
||||
console.log('');
|
||||
console.log('Welcome DM queued — the agent will greet you shortly.');
|
||||
}
|
||||
@@ -288,7 +252,11 @@ async function main(): Promise<void> {
|
||||
* Throws if the socket isn't reachable — this script requires the service
|
||||
* to be running.
|
||||
*/
|
||||
async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise<void> {
|
||||
async function sendWelcomeViaCliSocket(
|
||||
dmMg: MessagingGroup,
|
||||
welcome: string,
|
||||
identity: { senderId: string; sender: string },
|
||||
): Promise<void> {
|
||||
const sockPath = path.join(DATA_DIR, 'cli.sock');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -318,6 +286,8 @@ async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): P
|
||||
const payload =
|
||||
JSON.stringify({
|
||||
text: welcome,
|
||||
senderId: identity.senderId,
|
||||
sender: identity.sender,
|
||||
to: {
|
||||
channelType: dmMg.channel_type,
|
||||
platformId: dmMg.platform_id,
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
@@ -72,6 +80,11 @@ install_deps() {
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Corepack's first-use "Do you want to continue? [Y/n]" prompt would hang
|
||||
# the script since we redirect stdout/stderr to the log file — the prompt
|
||||
# is invisible but corepack still blocks on stdin. Auto-accept.
|
||||
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# Enable corepack so `pnpm` shim lands on PATH.
|
||||
log "Enabling corepack"
|
||||
corepack enable >> "$LOG_FILE" 2>&1 || true
|
||||
@@ -131,6 +144,16 @@ log "=== Bootstrap started ==="
|
||||
detect_platform
|
||||
|
||||
check_node
|
||||
if [ "$NODE_OK" = "false" ]; then
|
||||
log "Node missing or too old — running setup/install-node.sh"
|
||||
echo "Node not found — installing via setup/install-node.sh"
|
||||
if bash "$PROJECT_ROOT/setup/install-node.sh" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
hash -r 2>/dev/null || true
|
||||
check_node
|
||||
else
|
||||
log "install-node.sh failed"
|
||||
fi
|
||||
fi
|
||||
install_deps
|
||||
check_build_tools
|
||||
|
||||
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 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"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
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
|
||||
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
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.
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/telegram.ts \
|
||||
src/channels/telegram-pairing.ts \
|
||||
src/channels/telegram-pairing.test.ts \
|
||||
src/channels/telegram-markdown-sanitize.ts \
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f"
|
||||
done
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
|
||||
echo "import './telegram.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
# Register pair-telegram step if not already in the STEPS map.
|
||||
# Uses node (not sed) since sed's in-place + escape semantics differ
|
||||
# between BSD (macOS) and GNU.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = "setup/index.ts";
|
||||
let s = fs.readFileSync(p, "utf-8");
|
||||
if (!s.includes("\047pair-telegram\047")) {
|
||||
s = s.replace(
|
||||
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
|
||||
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
|
||||
);
|
||||
fs.writeFileSync(p, s);
|
||||
}
|
||||
'
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# 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
|
||||
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
|
||||
fi
|
||||
|
||||
# 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 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 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}" >&2 2>/dev/null \
|
||||
|| open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
Linux)
|
||||
xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \
|
||||
|| xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
log "Restarting service so the new adapter picks up the token…"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Telegram adapter a moment to finish starting before pair-telegram
|
||||
# begins polling for the user's code message.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`.
|
||||
*
|
||||
* Responsibility: orchestrate the sequence of steps end-to-end and route
|
||||
* between them. The runner, spawning, status parsing, spinner, abort, and
|
||||
* prompt primitives live in `setup/lib/runner.ts`; theming in
|
||||
* `setup/lib/theme.ts`; Telegram's full flow in `setup/channels/telegram.ts`.
|
||||
*
|
||||
* Config via env:
|
||||
* NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the
|
||||
* prompt. Defaults to $USER.
|
||||
* NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
|
||||
* channel flow). The CLI scratch agent is always
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|channel|verify)
|
||||
*
|
||||
* Timezone defaults to the host system's TZ. Run
|
||||
* pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>
|
||||
* later if autodetect is wrong.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { brandBold, brandChip } from './lib/theme.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
|
||||
const skip = new Set(
|
||||
(process.env.NANOCLAW_SKIP ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (!skip.has('environment')) {
|
||||
const res = await runQuietStep('environment', {
|
||||
running: 'Checking your system…',
|
||||
done: 'Your system looks good.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
'environment',
|
||||
"Your system doesn't look quite right.",
|
||||
'See logs/setup-steps/ for details, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(
|
||||
k.dim(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('container', {
|
||||
running: 'Preparing the sandbox your assistant runs in…',
|
||||
done: 'Sandbox ready.',
|
||||
failed: "Couldn't prepare the sandbox.",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'runtime_not_available') {
|
||||
fail(
|
||||
'container',
|
||||
"Docker isn't available.",
|
||||
'Install Docker Desktop (or start it if already installed), then retry.',
|
||||
);
|
||||
}
|
||||
if (err === 'docker_group_not_active') {
|
||||
fail(
|
||||
'container',
|
||||
"Docker was just installed but your shell doesn't know yet.",
|
||||
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
|
||||
);
|
||||
}
|
||||
fail(
|
||||
'container',
|
||||
"Couldn't build the sandbox.",
|
||||
'If Docker has a stale cache, try: `docker builder prune -f`, then retry.',
|
||||
);
|
||||
}
|
||||
maybeReexecUnderSg();
|
||||
}
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
k.dim(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('onecli', {
|
||||
running: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
fail(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('auth')) {
|
||||
await runAuthStep();
|
||||
}
|
||||
|
||||
if (!skip.has('mounts')) {
|
||||
const res = await runQuietStep(
|
||||
'mounts',
|
||||
{
|
||||
running: "Setting your assistant's access rules…",
|
||||
done: 'Access rules set.',
|
||||
skipped: 'Access rules already set.',
|
||||
},
|
||||
['--empty'],
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail('mounts', "Couldn't write access rules.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('service')) {
|
||||
const res = await runQuietStep('service', {
|
||||
running: 'Starting NanoClaw in the background…',
|
||||
done: 'NanoClaw is running.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
'service',
|
||||
"Couldn't start NanoClaw.",
|
||||
'See logs/nanoclaw.error.log for details.',
|
||||
);
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn(
|
||||
"NanoClaw's permissions need a tweak before it can reach Docker.",
|
||||
);
|
||||
p.log.message(
|
||||
k.dim(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||
' systemctl --user restart nanoclaw',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let displayName: string | undefined;
|
||||
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
||||
if (needsDisplayName) {
|
||||
const fallback = process.env.USER?.trim() || 'Operator';
|
||||
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
||||
displayName = preset || (await askDisplayName(fallback));
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent')) {
|
||||
const res = await runQuietStep(
|
||||
'cli-agent',
|
||||
{
|
||||
running: 'Setting up your terminal chat…',
|
||||
done: 'Terminal chat ready. Try `pnpm run chat hi`.',
|
||||
},
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
'cli-agent',
|
||||
"Couldn't set up the terminal chat.",
|
||||
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
const choice = await askChannelChoice();
|
||||
if (choice === 'telegram') {
|
||||
await runTelegramChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
"No messaging app for now. You can add one later (like Telegram, Slack, or Discord).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('verify')) {
|
||||
const res = await runQuietStep('verify', {
|
||||
running: 'Making sure everything works together…',
|
||||
done: "Everything's connected.",
|
||||
failed: 'A few things still need your attention.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const notes: string[] = [];
|
||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||
}
|
||||
const agentPing = res.terminal?.fields.AGENT_PING;
|
||||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||||
notes.push(
|
||||
"• Your assistant didn't reply to a test message. " +
|
||||
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
);
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
}
|
||||
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Chat in the terminal:', 'pnpm run chat hi'],
|
||||
["See what's happening:", 'tail -f logs/nanoclaw.log'],
|
||||
['Open Claude Code:', 'claude'],
|
||||
];
|
||||
const labelWidth = Math.max(...rows.map(([l]) => l.length));
|
||||
const nextSteps = rows
|
||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||
.join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
setupLog.complete(Date.now() - RUN_START);
|
||||
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
||||
}
|
||||
|
||||
// ─── auth step (select → branch) ────────────────────────────────────────
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success('Your Claude account is already connected.');
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||||
return;
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'How would you like to connect to Claude?',
|
||||
options: [
|
||||
{
|
||||
value: 'subscription',
|
||||
label: 'Sign in with my Claude subscription',
|
||||
hint: 'recommended if you have Pro or Max',
|
||||
},
|
||||
{
|
||||
value: 'oauth',
|
||||
label: 'Paste an OAuth token I already have',
|
||||
hint: 'sk-ant-oat…',
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: 'Paste an Anthropic API key',
|
||||
hint: 'pay-per-use via console.anthropic.com',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'subscription' | 'oauth' | 'api';
|
||||
setupLog.userInput('auth_method', method);
|
||||
|
||||
if (method === 'subscription') {
|
||||
await runSubscriptionAuth();
|
||||
} else {
|
||||
await runPasteAuth(method);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step("Opening the Claude sign-in flow…");
|
||||
console.log(
|
||||
k.dim(' (a browser will open for sign-in; 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,
|
||||
METHOD: 'subscription',
|
||||
});
|
||||
fail(
|
||||
'auth',
|
||||
"Couldn't complete the Claude sign-in.",
|
||||
'Re-run setup and try again, or choose a paste option instead.',
|
||||
);
|
||||
}
|
||||
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
|
||||
p.log.success('Claude account connected.');
|
||||
}
|
||||
|
||||
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
const label = method === 'oauth' ? 'OAuth token' : 'API key';
|
||||
const prefix = method === 'oauth' ? 'sk-ant-oat' : 'sk-ant-api';
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: `Paste your ${label}`,
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return 'Required';
|
||||
if (!v.trim().startsWith(prefix)) {
|
||||
return `Should start with ${prefix}…`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
|
||||
const res = await runQuietChild(
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets', 'create',
|
||||
'--name', 'Anthropic',
|
||||
'--type', 'anthropic',
|
||||
'--value', token,
|
||||
'--host-pattern', 'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
running: `Saving your ${label} to your OneCLI vault…`,
|
||||
done: 'Claude account connected.',
|
||||
},
|
||||
{
|
||||
extraFields: { METHOD: method },
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
'auth',
|
||||
`Couldn't save your ${label} to the vault.`,
|
||||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── prompts owned by the sequencer ────────────────────────────────────
|
||||
|
||||
async function askDisplayName(fallback: string): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant call you?',
|
||||
placeholder: fallback,
|
||||
defaultValue: fallback,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || fallback;
|
||||
setupLog.userInput('display_name', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Want to chat with your assistant from your phone?',
|
||||
options: [
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
return choice as 'telegram' | 'skip';
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
function anthropicSecretExists(): boolean {
|
||||
try {
|
||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (res.status !== 0) return false;
|
||||
return /anthropic/i.test(res.stdout ?? '');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runInheritScript(cmd: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { stdio: 'inherit' });
|
||||
child.on('close', (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After installing Docker, this process's supplementary groups are still
|
||||
* frozen from login — subsequent steps that talk to /var/run/docker.sock
|
||||
* (onecli install, service start, …) fail with EACCES even though the
|
||||
* daemon is up. Detect that and re-exec the whole driver under `sg docker`
|
||||
* so the rest of the run inherits the docker group without a re-login.
|
||||
*/
|
||||
function maybeReexecUnderSg(): void {
|
||||
if (process.env.NANOCLAW_REEXEC_SG === '1') return;
|
||||
if (process.platform !== 'linux') return;
|
||||
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
||||
if (info.status === 0) return;
|
||||
const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`;
|
||||
if (!/permission denied/i.test(err)) return;
|
||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||
|
||||
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
|
||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||
});
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
// ─── intro + progression-log init ──────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||
const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1';
|
||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
p.intro(
|
||||
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// When we were called via nanoclaw.sh, the wordmark + subtitle were
|
||||
// already printed in bash. Just open the clack gutter with a short,
|
||||
// neutral intro so the flow continues without duplication.
|
||||
if (isBootstrapped) {
|
||||
p.intro(k.dim("Let's get you set up."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${wordmark}`);
|
||||
console.log(` ${k.dim('Setting up your personal AI assistant')}`);
|
||||
p.intro(k.dim("Let's get you set up."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Telegram channel flow for setup:auto.
|
||||
*
|
||||
* `runTelegramChannel(displayName)` owns the full branch from the
|
||||
* BotFather instructions through the welcome DM:
|
||||
*
|
||||
* 1. BotFather instructions (clack note)
|
||||
* 2. Paste the bot token (clack password) — format-validated
|
||||
* 3. getMe via the Bot API to resolve the bot's username
|
||||
* 4. Install the adapter (setup/add-telegram.sh, non-interactive)
|
||||
* 5. Run the pair-telegram step, rendering code events as clack notes
|
||||
* 6. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user,
|
||||
* structured entries in logs/setup.log, full raw output in per-step files
|
||||
* under logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
const token = await collectTelegramToken();
|
||||
const botUsername = await validateTelegramToken(token);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'telegram-install',
|
||||
'bash',
|
||||
['setup/add-telegram.sh'],
|
||||
{
|
||||
running: `Connecting Telegram to @${botUsername}…`,
|
||||
done: 'Telegram connected.',
|
||||
},
|
||||
{
|
||||
env: { TELEGRAM_BOT_TOKEN: token },
|
||||
extraFields: { BOT_USERNAME: botUsername },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'telegram-install',
|
||||
"Couldn't connect Telegram.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const pair = await runPairTelegram();
|
||||
if (!pair.ok) {
|
||||
fail(
|
||||
'pair-telegram',
|
||||
"Couldn't pair with Telegram.",
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const platformId = pair.terminal?.fields.PLATFORM_ID;
|
||||
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
|
||||
if (!platformId || !pairedUserId) {
|
||||
fail(
|
||||
'pair-telegram',
|
||||
'Pairing completed but came back incomplete.',
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'telegram',
|
||||
'--user-id', pairedUserId,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to your Telegram chat…`,
|
||||
done: `${agentName} is ready. Check Telegram for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTelegramToken(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"Your assistant talks to you through a Telegram bot you create.",
|
||||
"Here's how:",
|
||||
'',
|
||||
' 1. Open Telegram and message @BotFather',
|
||||
' 2. Send /newbot and follow the prompts',
|
||||
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
|
||||
'',
|
||||
k.dim('Planning to add your assistant to group chats? In @BotFather:'),
|
||||
k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'),
|
||||
].join('\n'),
|
||||
'Set up your 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 "That doesn't look right. It should be <digits>:<chars>";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'telegram_token',
|
||||
`${token.slice(0, 12)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateTelegramToken(token: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
result?: { username?: string; id?: number };
|
||||
description?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.result?.username) {
|
||||
const username = data.result.username;
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: username,
|
||||
BOT_ID: data.result.id ?? '',
|
||||
});
|
||||
return username;
|
||||
}
|
||||
const reason = data.description ?? 'token rejected by Telegram';
|
||||
s.stop(`Telegram didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
'telegram-validate',
|
||||
"Telegram didn't accept that token.",
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
'telegram-validate',
|
||||
"Couldn't reach Telegram.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPairTelegram(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('pair-telegram');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Generating a secret code for your bot…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number) => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
const result = await spawnStep(
|
||||
'pair-telegram',
|
||||
['--intent', 'main'],
|
||||
(block: Block) => {
|
||||
if (block.type === 'PAIR_TELEGRAM_CODE') {
|
||||
const reason = block.fields.REASON ?? 'initial';
|
||||
if (reason === 'initial') {
|
||||
stopSpinner('Your secret code is ready.');
|
||||
} else {
|
||||
stopSpinner("Old code expired. Here's a fresh one.");
|
||||
}
|
||||
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start('Waiting for you to send the code from Telegram…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
||||
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
|
||||
s.start('Waiting for the correct code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||
if (block.fields.STATUS === 'success') {
|
||||
stopSpinner('Telegram paired.');
|
||||
} else {
|
||||
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
// Safety net: if the child died without emitting a terminal block, make
|
||||
// sure we don't leave the spinner running.
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('pair-telegram', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
function formatCodeCard(code: string): string {
|
||||
const spaced = code.split('').join(' ');
|
||||
return [
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
k.dim(' Send this code to your bot from Telegram.'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
+102
-15
@@ -2,13 +2,69 @@
|
||||
* Step: container — Build container image and verify with test run.
|
||||
* Replaces 03-setup-container.sh
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { setTimeout as sleep } from 'timers/promises';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists } from './platform.js';
|
||||
import { commandExists, getPlatform } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
type DockerStatus = 'ok' | 'no-permission' | 'no-daemon' | 'other';
|
||||
|
||||
function dockerStatus(): DockerStatus {
|
||||
const res = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
||||
if (res.status === 0) return 'ok';
|
||||
const err = `${res.stderr ?? ''}\n${res.stdout ?? ''}`;
|
||||
if (/permission denied/i.test(err)) return 'no-permission';
|
||||
if (/cannot connect|is the docker daemon running|no such file/i.test(err)) return 'no-daemon';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function dockerRunning(): boolean {
|
||||
return dockerStatus() === 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to start Docker if it's installed but idle. Poll up to 60s for the
|
||||
* daemon to come up — but bail immediately if the socket is reachable and
|
||||
* only blocked by a group-permission error, since that won't resolve by
|
||||
* waiting (the caller handles the sg re-exec for that case).
|
||||
*/
|
||||
async function tryStartDocker(): Promise<DockerStatus> {
|
||||
const platform = getPlatform();
|
||||
log.info('Docker not running — attempting to start', { platform });
|
||||
|
||||
try {
|
||||
if (platform === 'macos') {
|
||||
execSync('open -a Docker', { stdio: 'ignore' });
|
||||
} else if (platform === 'linux') {
|
||||
// Inherit stdio so sudo can prompt for a password if needed.
|
||||
execSync('sudo systemctl start docker', { stdio: 'inherit' });
|
||||
} else {
|
||||
return 'other';
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Start command failed', { err });
|
||||
return 'other';
|
||||
}
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await sleep(2000);
|
||||
const s = dockerStatus();
|
||||
if (s === 'ok') {
|
||||
log.info('Docker is up');
|
||||
return 'ok';
|
||||
}
|
||||
if (s === 'no-permission') {
|
||||
log.info('Docker daemon is up but socket is not accessible (group membership)');
|
||||
return 'no-permission';
|
||||
}
|
||||
}
|
||||
log.warn('Docker did not become ready within 60s');
|
||||
return 'no-daemon';
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { runtime: string } {
|
||||
// `--runtime` is still accepted for backwards compatibility with the /setup
|
||||
// skill, but `docker` is the only supported value.
|
||||
@@ -41,6 +97,15 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
if (!commandExists('docker')) {
|
||||
log.info('Docker not found — running setup/install-docker.sh');
|
||||
try {
|
||||
execSync('bash setup/install-docker.sh', { cwd: projectRoot, stdio: 'inherit' });
|
||||
} catch (err) {
|
||||
log.warn('install-docker.sh failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
if (!commandExists('docker')) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
@@ -54,19 +119,41 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
} catch {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'runtime_not_available',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
{
|
||||
let status = dockerStatus();
|
||||
if (status !== 'ok') {
|
||||
status = await tryStartDocker();
|
||||
}
|
||||
|
||||
// Socket is unreachable due to group perms — current shell's supplementary
|
||||
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
|
||||
// or a prior install) doesn't affect us until next login. Re-exec this
|
||||
// step under `sg docker` so the child picks up docker as its primary
|
||||
// group and can talk to /var/run/docker.sock without a logout.
|
||||
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
|
||||
log.info('Re-executing container step under `sg docker`');
|
||||
const res = spawnSync(
|
||||
'sg',
|
||||
['docker', '-c', 'pnpm exec tsx setup/index.ts --step container'],
|
||||
{ cwd: projectRoot, stdio: 'inherit' },
|
||||
);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
if (status !== 'ok') {
|
||||
const error =
|
||||
status === 'no-permission' ? 'docker_group_not_active' : 'runtime_not_available';
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: error,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const buildCmd = 'docker build';
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Step runner + abort helpers for setup:auto.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Stream-parse setup-step status blocks (`=== NANOCLAW SETUP: … ===`)
|
||||
* - Spawn children with output tee'd to a per-step raw log (level 3)
|
||||
* - Wrap each run in a clack spinner with live elapsed time (level 1)
|
||||
* - Append a structured entry to the progression log (level 2) via
|
||||
* `setup/logs.ts` when the run ends
|
||||
* - Abort helpers (`fail`, `ensureAnswer`) used by step orchestrators
|
||||
*
|
||||
* See docs/setup-flow.md for the three-level output contract.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
|
||||
export type Fields = Record<string, string>;
|
||||
export type Block = { type: string; fields: Fields };
|
||||
|
||||
export type StepResult = {
|
||||
ok: boolean;
|
||||
exitCode: number;
|
||||
blocks: Block[];
|
||||
transcript: string;
|
||||
/** The last block with a STATUS field (the terminal/result block). */
|
||||
terminal: Block | null;
|
||||
};
|
||||
|
||||
export type QuietChildResult = {
|
||||
ok: boolean;
|
||||
exitCode: number;
|
||||
transcript: string;
|
||||
terminal: Block | null;
|
||||
blocks: Block[];
|
||||
};
|
||||
|
||||
export type SpinnerLabels = {
|
||||
running: string;
|
||||
done: string;
|
||||
skipped?: string;
|
||||
failed?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each
|
||||
* block as it closes so the UI can react mid-stream (e.g. render a pairing
|
||||
* code card as soon as pair-telegram emits it, rather than after the step
|
||||
* has finished).
|
||||
*/
|
||||
export class StatusStream {
|
||||
private lineBuf = '';
|
||||
private current: Block | null = null;
|
||||
readonly blocks: Block[] = [];
|
||||
transcript = '';
|
||||
|
||||
constructor(private readonly onBlock: (block: Block) => void) {}
|
||||
|
||||
write(chunk: string): void {
|
||||
this.transcript += chunk;
|
||||
this.lineBuf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = this.lineBuf.indexOf('\n')) !== -1) {
|
||||
const line = this.lineBuf.slice(0, idx);
|
||||
this.lineBuf = this.lineBuf.slice(idx + 1);
|
||||
this.processLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
private processLine(line: string): void {
|
||||
const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/);
|
||||
if (start) {
|
||||
this.current = { type: start[1], fields: {} };
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('=== END ===')) {
|
||||
if (this.current) {
|
||||
this.blocks.push(this.current);
|
||||
this.onBlock(this.current);
|
||||
this.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.current) return;
|
||||
const colon = line.indexOf(':');
|
||||
if (colon === -1) return;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
if (key) this.current.fields[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a setup step as a child process. Output is tee'd to the provided
|
||||
* raw log file (level 3) and parsed for status blocks (level 2 summary).
|
||||
* The onBlock callback fires per status block as they close so the UI can
|
||||
* react mid-stream.
|
||||
*/
|
||||
export function spawnStep(
|
||||
stepName: string,
|
||||
extra: string[],
|
||||
onBlock: (block: Block) => void,
|
||||
rawLogPath: string,
|
||||
): Promise<StepResult> {
|
||||
return new Promise((resolve) => {
|
||||
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
|
||||
if (extra.length > 0) args.push('--', ...extra);
|
||||
|
||||
const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stream = new StatusStream(onBlock);
|
||||
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
|
||||
raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stream.write(chunk.toString('utf-8'));
|
||||
raw.write(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stream.transcript += chunk.toString('utf-8');
|
||||
raw.write(chunk);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
raw.end();
|
||||
const terminal =
|
||||
[...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null;
|
||||
const status = terminal?.fields.STATUS;
|
||||
const ok = code === 0 && (status === 'success' || status === 'skipped');
|
||||
resolve({
|
||||
ok,
|
||||
exitCode: code ?? 1,
|
||||
blocks: stream.blocks,
|
||||
transcript: stream.transcript,
|
||||
terminal,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function spawnQuiet(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
rawLogPath: string,
|
||||
envOverride?: NodeJS.ProcessEnv,
|
||||
): Promise<QuietChildResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: envOverride ? { ...process.env, ...envOverride } : process.env,
|
||||
});
|
||||
let transcript = '';
|
||||
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
|
||||
raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`);
|
||||
const blocks: Block[] = [];
|
||||
const stream = new StatusStream((b) => blocks.push(b));
|
||||
child.stdout.on('data', (c: Buffer) => {
|
||||
const s = c.toString('utf-8');
|
||||
transcript += s;
|
||||
stream.write(s);
|
||||
raw.write(c);
|
||||
});
|
||||
child.stderr.on('data', (c: Buffer) => {
|
||||
transcript += c.toString('utf-8');
|
||||
raw.write(c);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
raw.end();
|
||||
const terminal =
|
||||
[...blocks].reverse().find((b) => b.fields.STATUS) ?? null;
|
||||
resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */
|
||||
export async function runQuietStep(
|
||||
stepName: string,
|
||||
labels: SpinnerLabels,
|
||||
extra: string[] = [],
|
||||
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
||||
const rawLog = setupLog.stepRawLog(stepName);
|
||||
const start = Date.now();
|
||||
const result = await runUnderSpinner(labels, () =>
|
||||
spawnStep(stepName, extra, () => {}, rawLog),
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
writeStepEntry(stepName, result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */
|
||||
export async function runQuietChild(
|
||||
logName: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
labels: SpinnerLabels,
|
||||
opts?: {
|
||||
/** Extra fields to merge into the progression entry (on top of any status-block fields). */
|
||||
extraFields?: Record<string, string | number | boolean>;
|
||||
/** Environment overrides to pass to the child process. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
): Promise<QuietChildResult & { rawLog: string; durationMs: number }> {
|
||||
const rawLog = setupLog.stepRawLog(logName);
|
||||
const start = Date.now();
|
||||
const result = await runUnderSpinner(labels, () =>
|
||||
spawnQuiet(cmd, args, rawLog, opts?.env),
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
const blockFields = summariseTerminalFields(result.terminal);
|
||||
const fields = { ...blockFields, ...(opts?.extraFields ?? {}) };
|
||||
const rawStatus = result.terminal?.fields.STATUS;
|
||||
const status: 'success' | 'skipped' | 'failed' = !result.ok
|
||||
? 'failed'
|
||||
: rawStatus === 'skipped'
|
||||
? 'skipped'
|
||||
: 'success';
|
||||
setupLog.step(logName, status, durationMs, fields, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/** Turn a step's terminal-block fields into a concise progression-log entry. */
|
||||
export function writeStepEntry(
|
||||
stepName: string,
|
||||
result: StepResult,
|
||||
durationMs: number,
|
||||
rawLog: string,
|
||||
): void {
|
||||
const rawStatus = result.terminal?.fields.STATUS;
|
||||
const logStatus: 'success' | 'skipped' | 'failed' = !result.ok
|
||||
? 'failed'
|
||||
: rawStatus === 'skipped'
|
||||
? 'skipped'
|
||||
: 'success';
|
||||
const fields = summariseTerminalFields(result.terminal);
|
||||
setupLog.step(stepName, logStatus, durationMs, fields, rawLog);
|
||||
}
|
||||
|
||||
/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */
|
||||
export function summariseTerminalFields(block: Block | null): Record<string, string> {
|
||||
if (!block) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(block.fields)) {
|
||||
if (k === 'STATUS' || k === 'LOG') continue;
|
||||
if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runUnderSpinner<
|
||||
T extends { ok: boolean; transcript: string; terminal?: Block | null },
|
||||
>(
|
||||
labels: SpinnerLabels,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start(labels.running);
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await work();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function dumpTranscriptOnFailure(transcript: string): void {
|
||||
const lines = transcript.split('\n').filter((l) => {
|
||||
if (l.startsWith('=== NANOCLAW SETUP:')) return false;
|
||||
if (l.startsWith('=== END ===')) return false;
|
||||
return true;
|
||||
});
|
||||
const tail = lines.slice(-40).join('\n').trimEnd();
|
||||
if (tail) {
|
||||
console.log();
|
||||
console.log(k.dim(tail));
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the setup run with a user-facing error, logging the abort to the
|
||||
* progression log. Takes the step name explicitly so callers are clear
|
||||
* about which step they're failing from — no hidden module state.
|
||||
*/
|
||||
export function fail(stepName: string, msg: string, hint?: string): never {
|
||||
setupLog.abort(stepName, msg);
|
||||
p.log.error(msg);
|
||||
if (hint) p.log.message(k.dim(hint));
|
||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
||||
p.cancel('Setup aborted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a clack prompt result. If the user cancelled (Ctrl-C / Esc), exit
|
||||
* gracefully. Cancel is exit 0 — it's not an abort worth logging to the
|
||||
* progression log, since the operator initiated it deliberately.
|
||||
*/
|
||||
export function ensureAnswer<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel('Setup cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* NanoClaw brand palette for the terminal.
|
||||
*
|
||||
* Colors pulled from assets/nanoclaw-logo.png:
|
||||
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
|
||||
* brand navy ≈ #171B3B — the dark logo background + outlines
|
||||
*
|
||||
* Rendering gates:
|
||||
* - No TTY (piped / redirected) → plain text, no ANSI
|
||||
* - NO_COLOR set → plain text, no ANSI
|
||||
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
|
||||
* - Otherwise → kleur's 16-color cyan (closest fallback)
|
||||
*/
|
||||
import k from 'kleur';
|
||||
|
||||
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
||||
const TRUECOLOR =
|
||||
USE_ANSI &&
|
||||
(process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit');
|
||||
|
||||
export function brand(s: string): string {
|
||||
if (!USE_ANSI) return s;
|
||||
if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
|
||||
return k.cyan(s);
|
||||
}
|
||||
|
||||
export function brandBold(s: string): string {
|
||||
if (!USE_ANSI) return s;
|
||||
if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`;
|
||||
return k.bold(k.cyan(s));
|
||||
}
|
||||
|
||||
export function brandChip(s: string): string {
|
||||
if (!USE_ANSI) return s;
|
||||
if (TRUECOLOR) {
|
||||
return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`;
|
||||
}
|
||||
return k.bgCyan(k.black(k.bold(s)));
|
||||
}
|
||||
+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`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
||||
* operator to send the code from the chat they want to register.
|
||||
*
|
||||
* Emits machine-readable status blocks only. The parent driver
|
||||
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
|
||||
* this step directly will look sparse — that's intentional.
|
||||
*
|
||||
* Blocks emitted:
|
||||
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
|
||||
* PAIR_TELEGRAM_ATTEMPT { CANDIDATE }
|
||||
* PAIR_TELEGRAM (final) { STATUS=success, CODE, INTENT, PLATFORM_ID,
|
||||
* IS_GROUP, PAIRED_USER_ID }
|
||||
* or { STATUS=failed, CODE, ERROR }
|
||||
*
|
||||
* Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh
|
||||
* copies in from the `channels` branch before this step runs. setup/ is
|
||||
* excluded from the host tsconfig, so this file's import resolves only at
|
||||
* runtime — tsc won't complain on branches that haven't run add-telegram yet.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
createPairing,
|
||||
waitForPairing,
|
||||
type PairingIntent,
|
||||
} from '../src/channels/telegram-pairing.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
function parseArgs(args: string[]): PairingIntent {
|
||||
let intent: PairingIntent = 'main';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--intent') {
|
||||
const raw = args[++i] || 'main';
|
||||
if (raw === 'main') {
|
||||
intent = 'main';
|
||||
} else if (raw.startsWith('wire-to:')) {
|
||||
intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) };
|
||||
} else if (raw.startsWith('new-agent:')) {
|
||||
intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) };
|
||||
} else {
|
||||
throw new Error(`Unknown intent: ${raw}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
function intentToString(intent: PairingIntent): string {
|
||||
if (intent === 'main') return 'main';
|
||||
return `${intent.kind}:${intent.folder}`;
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const intent = parseArgs(args);
|
||||
|
||||
// Pairing stores state under DATA_DIR; the DB isn't strictly needed for the
|
||||
// pairing primitive itself, but the inbound interceptor running inside the
|
||||
// live service needs migrations applied. Touch it here so a fresh install
|
||||
// doesn't fail on the first code match.
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db);
|
||||
|
||||
const MAX_REGENERATIONS = 5;
|
||||
let record = await createPairing(intent);
|
||||
emitStatus('PAIR_TELEGRAM_CODE', {
|
||||
CODE: record.code,
|
||||
REASON: 'initial',
|
||||
});
|
||||
|
||||
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
||||
try {
|
||||
const consumed = await waitForPairing(record.code, {
|
||||
onAttempt: (a) => {
|
||||
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
|
||||
CANDIDATE: a.candidate,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'success',
|
||||
CODE: record.code,
|
||||
INTENT: intentToString(consumed.intent),
|
||||
PLATFORM_ID: consumed.consumed!.platformId,
|
||||
IS_GROUP: consumed.consumed!.isGroup,
|
||||
PAIRED_USER_ID: consumed.consumed!.adminUserId
|
||||
? `telegram:${consumed.consumed!.adminUserId}`
|
||||
: '',
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const invalidated = /invalidated by wrong code/.test(message);
|
||||
if (invalidated && regen < MAX_REGENERATIONS) {
|
||||
record = await createPairing(intent);
|
||||
emitStatus('PAIR_TELEGRAM_CODE', {
|
||||
CODE: record.code,
|
||||
REASON: 'regenerated',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reason = invalidated ? 'max-regenerations-exceeded' : message;
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'failed',
|
||||
CODE: record.code,
|
||||
ERROR: reason,
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Register a Claude subscription OAuth token with OneCLI — the *only* auth
|
||||
# path that needs a TTY break in the flow. Paste-based paths (existing
|
||||
# OAuth token / API key) are handled in-process by setup/auto.ts using
|
||||
# clack prompts, then onecli secrets create is invoked directly from TS.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
|
||||
# OAuth dance works and its token is captured into a tempfile.
|
||||
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
|
||||
# 3. Register it with OneCLI.
|
||||
#
|
||||
# Env overrides:
|
||||
# SECRET_NAME OneCLI secret name (default: Anthropic)
|
||||
# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com)
|
||||
|
||||
# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in
|
||||
# /bin/bash, but Homebrew users usually have 5.x first on PATH. The
|
||||
# readline preload is optional — on 3.x we fall back to a plain prompt.
|
||||
|
||||
SECRET_NAME="${SECRET_NAME:-Anthropic}"
|
||||
HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}"
|
||||
|
||||
command -v onecli >/dev/null \
|
||||
|| { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; }
|
||||
command -v claude >/dev/null \
|
||||
|| { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; }
|
||||
command -v script >/dev/null \
|
||||
|| { echo "script(1) is required for PTY capture." >&2; exit 1; }
|
||||
|
||||
tmpfile=$(mktemp -t claude-setup-token.XXXXXX)
|
||||
trap 'rm -f "$tmpfile"' EXIT
|
||||
|
||||
cat <<'EOF'
|
||||
A browser window will open for you to sign in with your Claude account.
|
||||
When you finish, we'll save the token to your OneCLI vault automatically.
|
||||
|
||||
Press Enter to continue, or edit the command first.
|
||||
|
||||
EOF
|
||||
|
||||
cmd="claude setup-token"
|
||||
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
|
||||
# bash 4+: pre-fill the readline buffer so Enter literally submits.
|
||||
read -r -e -i "$cmd" -p "$ " cmd </dev/tty
|
||||
else
|
||||
# bash 3.x (macOS default /bin/bash): no readline preload. Fall back.
|
||||
echo "$ $cmd"
|
||||
read -r -p "Press Enter to run, Ctrl-C to abort. " _ </dev/tty
|
||||
fi
|
||||
|
||||
# `script` arg order differs between BSD (macOS) and util-linux.
|
||||
if script --version 2>/dev/null | grep -q util-linux; then
|
||||
script -q -c "$cmd" "$tmpfile"
|
||||
else
|
||||
# BSD script: command is argv after the file, so let it word-split.
|
||||
# shellcheck disable=SC2086
|
||||
script -q "$tmpfile" $cmd
|
||||
fi
|
||||
|
||||
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
|
||||
# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255.
|
||||
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
|
||||
| tr -d '\n\r' \
|
||||
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
|
||||
| tail -1 || true)
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
|
||||
cp "$tmpfile" "$keep"
|
||||
echo >&2
|
||||
echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Got token: ${token:0:16}…${token: -4}"
|
||||
echo "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…"
|
||||
|
||||
onecli secrets create \
|
||||
--name "$SECRET_NAME" \
|
||||
--type anthropic \
|
||||
--value "$token" \
|
||||
--host-pattern "$HOST_PATTERN"
|
||||
|
||||
echo "Done."
|
||||
+25
-2
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import {
|
||||
commandExists,
|
||||
getPlatform,
|
||||
getNodePath,
|
||||
getServiceManager,
|
||||
@@ -255,12 +256,34 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||
fs.writeFileSync(unitPath, unit);
|
||||
log.info('Wrote systemd unit', { unitPath });
|
||||
|
||||
// Detect stale docker group before starting (user systemd only)
|
||||
const dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
|
||||
// Detect stale docker group before starting (user systemd only). The user
|
||||
// systemd manager is a long-running process whose group list is frozen at
|
||||
// login, so `usermod -aG docker` mid-session doesn't reach it. Rather than
|
||||
// require the user to log out + back in, punch a POSIX ACL onto the socket
|
||||
// that grants the current user rw directly. This is temporary — the socket
|
||||
// is recreated by dockerd on restart (and by then the user has relogged, so
|
||||
// normal group perms apply again).
|
||||
let dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
|
||||
if (dockerGroupStale) {
|
||||
log.warn(
|
||||
'Docker group not active in systemd session — user was likely added to docker group mid-session',
|
||||
);
|
||||
if (commandExists('setfacl')) {
|
||||
const user = execSync('whoami', { encoding: 'utf-8' }).trim();
|
||||
try {
|
||||
execSync(`sudo setfacl -m u:${user}:rw /var/run/docker.sock`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
log.info(
|
||||
'Applied temporary ACL to /var/run/docker.sock (resets on docker restart or reboot)',
|
||||
);
|
||||
dockerGroupStale = false;
|
||||
} catch (err) {
|
||||
log.warn('Failed to apply setfacl workaround', { err });
|
||||
}
|
||||
} else {
|
||||
log.warn('setfacl not installed — cannot apply automatic workaround');
|
||||
}
|
||||
}
|
||||
|
||||
// Kill orphaned nanoclaw processes to avoid channel connection conflicts
|
||||
|
||||
+58
-2
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
@@ -175,12 +175,22 @@ export async function run(_args: string[]): Promise<void> {
|
||||
mountAllowlist = 'configured';
|
||||
}
|
||||
|
||||
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
||||
// everything upstream looks healthy, since a broken socket would just hang.
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
|
||||
if (service === 'running' && registeredGroups > 0) {
|
||||
log.info('Pinging CLI agent');
|
||||
agentPing = await pingCliAgent();
|
||||
log.info('Agent ping result', { agentPing });
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
const status =
|
||||
service === 'running' &&
|
||||
credentials !== 'missing' &&
|
||||
anyChannelConfigured &&
|
||||
registeredGroups > 0
|
||||
registeredGroups > 0 &&
|
||||
(agentPing === 'ok' || agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
|
||||
@@ -194,9 +204,55 @@ export async function run(_args: string[]): Promise<void> {
|
||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||
REGISTERED_GROUPS: registeredGroups,
|
||||
MOUNT_ALLOWLIST: mountAllowlist,
|
||||
AGENT_PING: agentPing,
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-word message through the CLI channel and check for a reply.
|
||||
* Silent by default — stdout/stderr of the child are captured but not
|
||||
* forwarded. Kills the child after 90s so verify can't hang on a wedged
|
||||
* agent (chat.ts's own timeout is 120s, which is too long for setup).
|
||||
*/
|
||||
function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('pnpm', ['run', 'chat', 'ping'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
resolve('no_reply');
|
||||
}, 90_000);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
// chat.ts: exit 0 on reply, 2 on socket error, 3 on no reply.
|
||||
if (code === 2) {
|
||||
resolve('socket_error');
|
||||
} else if (code === 0 && stdout.trim().length > 0) {
|
||||
resolve('ok');
|
||||
} else {
|
||||
resolve('no_reply');
|
||||
}
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve('socket_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+4
-2
@@ -183,6 +183,8 @@ function createAdapter(): ChannelAdapter {
|
||||
text?: unknown;
|
||||
to?: unknown;
|
||||
reply_to?: unknown;
|
||||
sender?: unknown;
|
||||
senderId?: unknown;
|
||||
};
|
||||
try {
|
||||
payload = JSON.parse(line);
|
||||
@@ -209,8 +211,8 @@ function createAdapter(): ChannelAdapter {
|
||||
timestamp: new Date().toISOString(),
|
||||
content: JSON.stringify({
|
||||
text: payload.text,
|
||||
sender: 'cli',
|
||||
senderId: `cli:${PLATFORM_ID}`,
|
||||
sender: typeof payload.sender === 'string' ? payload.sender : 'cli',
|
||||
senderId: typeof payload.senderId === 'string' ? payload.senderId : `cli:${PLATFORM_ID}`,
|
||||
}),
|
||||
},
|
||||
replyTo: replyTo ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user