From 712a0e1e010f1abe796569dd8a60b6b506fc11a1 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 20 Apr 2026 15:18:35 +0000 Subject: [PATCH] feat(new-setup): wrap node/docker installs and add generic set-env step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three allowlist-friendly setup helpers so /new-setup and /new-setup-2 don't hit unmatchable commands during a fresh install: - setup/install-node.sh — idempotent Node 22 install wrapper (macOS via brew, Linux via NodeSource + apt). Replaces the raw `curl | sudo -E bash -` flow whose stdin-consuming `bash -` segment can't be pre-approved. - setup/install-docker.sh — same pattern for Docker (brew --cask on macOS, get.docker.com on Linux + usermod). - setup/set-env.ts — generic `--step set-env` that writes KEY=VALUE to .env (and optionally syncs to data/env/env) so channel-install flows don't invent `grep && sed && rm` pipelines, which split at each && and can't be tightly allowlisted. new-setup-2's Telegram path now uses set-env for TELEGRAM_BOT_TOKEN and explicitly skips /add-telegram's Credentials section. new-setup step 1 and step 2 now call the install wrappers; the raw curl/apt entries are gone from the allowed-tools list. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-setup-2/SKILL.md | 17 +++++-- .claude/skills/new-setup/SKILL.md | 13 ++--- setup/index.ts | 1 + setup/install-docker.sh | 56 +++++++++++++++++++++ setup/install-node.sh | 54 ++++++++++++++++++++ setup/set-env.ts | 77 +++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100755 setup/install-docker.sh create mode 100755 setup/install-node.sh create mode 100644 setup/set-env.ts diff --git a/.claude/skills/new-setup-2/SKILL.md b/.claude/skills/new-setup-2/SKILL.md index eedea15b1..1b98443aa 100644 --- a/.claude/skills/new-setup-2/SKILL.md +++ b/.claude/skills/new-setup-2/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup-2 description: Follow-on to /new-setup. Captures the operator and agent names, wires a real messaging channel, and adds quality-of-life extras. Linear rollthrough; every step is skippable. Invoked when the user picks "continue setup" at the end of /new-setup. -allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) +allowed-tools: Bash(bash setup/probe.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw phase-2 setup @@ -79,8 +79,19 @@ Print the list as a numbered plain-prose list (too many options for `AskUserQues When the user picks one: -1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call, then continue with credentials and pairing (invoke `/add-telegram` afterwards and its preflight will skip straight to Credentials). For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. -2. **Capture platform IDs.** After the `/add-` skill finishes, you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, for example, the `pair-telegram` step emits `PLATFORM_ID` and `ADMIN_USER_ID` in a status block once the user sends the 4-digit code. +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. + + **Telegram credentials (inline):** + - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. + - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). + - Persist the token and sync it to the container mount with the generic setter: + + ``` + pnpm exec tsx setup/index.ts --step set-env -- \ + --key TELEGRAM_BOT_TOKEN --value "" --sync-container + ``` + +2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. 3. **Wire the agent.** Run `init-first-agent.ts` in DM mode with `--no-cli-bonus` (this keeps the new agent off the CLI messaging group so the pre-existing throwaway agent still owns CLI routing cleanly): ``` diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index d15ba6664..02cef98ec 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. -allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat:*) Bash(curl -fsSL https://get.docker.com | sh) Bash(curl -fsSL https://deb.nodesource.com/setup_22.x) Bash(sudo apt-get install -y nodejs) Bash(sudo usermod -aG docker:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) --- # NanoClaw bare-minimum setup @@ -38,10 +38,7 @@ One permitted parallelism: Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place. -If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail: - -- macOS: `brew install node@22` -- Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs` +If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), run `bash setup/install-node.sh` **before** `bash setup.sh` — the script handles both macOS (via `brew`) and Linux/WSL (NodeSource + apt). It's idempotent and short-circuits when node is already on PATH. Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet. @@ -57,16 +54,14 @@ Parse the status block: Check probe results and skip if `DOCKER=running` AND `IMAGE_PRESENT=true`. **Runtime:** -- `DOCKER=not_found` → Docker itself is missing — install it so agent containers have an isolated place to run. - - macOS: `brew install --cask docker && open -a Docker` - - Linux: `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER` (tell user they may need to log out/in for group membership) +- `DOCKER=not_found` → Docker is missing — install it so agent containers have an isolated place to run. Run `bash setup/install-docker.sh` (handles macOS via `brew --cask` and Linux via the official get.docker.com script, and adds the user to the `docker` group on Linux). On Linux the user may need to log out/in for group membership to take effect. On macOS, launch Docker afterwards with `open -a Docker`. - `DOCKER=installed_not_running` → Docker is installed but the daemon is down — start it. - macOS: `open -a Docker` - Linux: `sudo systemctl start docker` Wait ~15s after either, then proceed. -> **Loose commands:** Docker install/start. Justification: platform-specific package-manager invocations. Wrapping them in a `--step` would just move the same branching into TypeScript with no added value. +> **Loose commands:** `open -a Docker`, `sudo systemctl start docker`. Justification: daemon-start is a one-liner per platform, not worth wrapping. The actual install (which had the unmatchable `curl | sh` pattern) is now inside `setup/install-docker.sh`. **Image (run if `IMAGE_PRESENT=false`):** build the agent container image — takes a few minutes the first time, one-off cost. diff --git a/setup/index.ts b/setup/index.ts index 526ea7d63..2112cd1e4 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -10,6 +10,7 @@ const STEPS: Record< () => Promise<{ run: (args: string[]) => Promise }> > = { timezone: () => import('./timezone.js'), + 'set-env': () => import('./set-env.js'), environment: () => import('./environment.js'), container: () => import('./container.js'), register: () => import('./register.js'), diff --git a/setup/install-docker.sh b/setup/install-docker.sh new file mode 100755 index 000000000..4aaadcecb --- /dev/null +++ b/setup/install-docker.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Setup helper: install-docker — bundles Docker install into one idempotent +# script so /new-setup can run it without needing `curl | sh` in the allowlist +# (pipelines split at matching time, and `sh` receiving stdin can't be +# pre-approved safely). +# +# The script itself is the allowlisted unit; the pipes and sudo live inside +# it. Starting the daemon (after install) stays separate — `open -a Docker` +# and `sudo systemctl start docker` are already in the allowlist. +set -euo pipefail + +echo "=== NANOCLAW SETUP: INSTALL_DOCKER ===" + +if command -v docker >/dev/null 2>&1; then + echo "STATUS: already-installed" + echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)" + echo "=== END ===" + exit 0 +fi + +case "$(uname -s)" in + Darwin) + echo "STEP: brew-install-docker" + if ! command -v brew >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run." + echo "=== END ===" + exit 1 + fi + brew install --cask docker + ;; + Linux) + echo "STEP: docker-get-script" + curl -fsSL https://get.docker.com | sh + echo "STEP: usermod-docker-group" + sudo usermod -aG docker "$USER" + echo "NOTE: you may need to log out and back in for docker group membership to take effect" + ;; + *) + echo "STATUS: failed" + echo "ERROR: Unsupported platform: $(uname -s)" + echo "=== END ===" + exit 1 + ;; +esac + +if ! command -v docker >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: docker not found on PATH after install" + echo "=== END ===" + exit 1 +fi + +echo "STATUS: installed" +echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)" +echo "=== END ===" diff --git a/setup/install-node.sh b/setup/install-node.sh new file mode 100755 index 000000000..e100ccd55 --- /dev/null +++ b/setup/install-node.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Setup helper: install-node — bundles Node 22 install into one idempotent +# script so /new-setup can run it without needing `curl | sudo -E bash -` in +# the allowlist (that pattern is inherently unmatchable — bash reads from +# stdin, so pre-approval can't inspect what's being executed). +# +# The script itself is the allowlisted unit; the pipes and sudo live inside +# it. Pure bash by design — runs before Node exists on the host. +set -euo pipefail + +echo "=== NANOCLAW SETUP: INSTALL_NODE ===" + +if command -v node >/dev/null 2>&1; then + echo "STATUS: already-installed" + echo "NODE_VERSION: $(node --version)" + echo "=== END ===" + exit 0 +fi + +case "$(uname -s)" in + Darwin) + echo "STEP: brew-install-node" + if ! command -v brew >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run." + echo "=== END ===" + exit 1 + fi + brew install node@22 + ;; + Linux) + echo "STEP: nodesource-setup" + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + echo "STEP: apt-install-nodejs" + sudo apt-get install -y nodejs + ;; + *) + echo "STATUS: failed" + echo "ERROR: Unsupported platform: $(uname -s)" + echo "=== END ===" + exit 1 + ;; +esac + +if ! command -v node >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: node not found on PATH after install" + echo "=== END ===" + exit 1 +fi + +echo "STATUS: installed" +echo "NODE_VERSION: $(node --version)" +echo "=== END ===" diff --git a/setup/set-env.ts b/setup/set-env.ts new file mode 100644 index 000000000..5ee4b4e84 --- /dev/null +++ b/setup/set-env.ts @@ -0,0 +1,77 @@ +/** + * Step: set-env — Write or update a KEY=VALUE in .env, with optional sync to + * data/env/env (the container-mounted copy). + * + * Usage: + * pnpm exec tsx setup/index.ts --step set-env -- \ + * --key TELEGRAM_BOT_TOKEN --value "" [--sync-container] + * + * Exists so channel-install flows don't have to invent grep/sed/rm pipelines + * (which can't be allowlisted tightly — sed can read any file, and each + * segment of an && chain is matched separately). + * + * Logs the key but never the value. + */ +import fs from 'fs'; +import path from 'path'; + +import { log } from '../src/log.js'; +import { emitStatus } from './status.js'; + +export async function run(args: string[]): Promise { + const keyIdx = args.indexOf('--key'); + const valueIdx = args.indexOf('--value'); + const syncContainer = args.includes('--sync-container'); + + if (keyIdx === -1 || !args[keyIdx + 1]) { + throw new Error('--key is required'); + } + if (valueIdx === -1 || args[valueIdx + 1] === undefined) { + throw new Error('--value is required'); + } + + const key = args[keyIdx + 1]; + const value = args[valueIdx + 1]; + + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) { + throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`); + } + + const projectRoot = process.cwd(); + const envFile = path.join(projectRoot, '.env'); + + let content = ''; + if (fs.existsSync(envFile)) { + content = fs.readFileSync(envFile, 'utf-8'); + } + + const lineRegex = new RegExp(`^${key}=.*$`, 'm'); + const newLine = `${key}=${value}`; + const existed = lineRegex.test(content); + + if (existed) { + content = content.replace(lineRegex, newLine); + } else { + const sep = content && !content.endsWith('\n') ? '\n' : ''; + content = content + sep + newLine + '\n'; + } + + fs.writeFileSync(envFile, content); + log.info('Updated .env', { key, existed }); + + let synced = false; + if (syncContainer) { + const dataEnvDir = path.join(projectRoot, 'data', 'env'); + fs.mkdirSync(dataEnvDir, { recursive: true }); + fs.copyFileSync(envFile, path.join(dataEnvDir, 'env')); + synced = true; + log.info('Synced .env to container mount', { path: 'data/env/env' }); + } + + emitStatus('SET_ENV', { + KEY: key, + EXISTED: existed, + SYNCED_TO_CONTAINER: synced, + STATUS: 'success', + }); +}