mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Merge v2 into channels
Picks up 105 commits from v2 (engage modes, sender/channel approval flows, host-sweep heartbeat lifecycle, setup/onecli refactor, setup-flow docs, DeliveryAddress/InboundEvent adapter contract changes). Retires 9 deprecated skills that moved out of this branch's scope: add-compact, add-gmail, add-image-vision, add-pdf-reader, add-reactions, add-telegram-swarm, add-voice-transcription, channel-formatting, use-local-whisper. Preserves channels-branch code: all 20 channel adapters (discord, slack, telegram, whatsapp, wechat, matrix, emacs, iMessage, github, linear, teams, gchat, webex, resend, whatsapp-cloud + helpers) plus chat-sdk deps. Conflicts resolved: - package.json: combined channels' adapter deps with v2's telegram bump (^4.24.0 → 4.26.0) and new @clack/prompts + kleur. - pnpm-lock.yaml: regenerated from v2 baseline via pnpm install. - setup/pair-telegram.ts: took v2's version (new PAIR_TELEGRAM_CODE block protocol; channels' older PAIR_TELEGRAM_ISSUED design superseded). Note: host TS build will fail on adapter type drift (adapter.ts renamed InboundMessage → InboundEvent; chat-sdk-bridge.ts rewritten). Fix in follow-up commits.
This commit is contained in:
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
|
||||
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
|
||||
# server-invite step live in setup/channels/discord.ts. Credentials come in via
|
||||
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_DISCORD) 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-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-discord] $*" >&2; }
|
||||
|
||||
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "DISCORD_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
|
||||
emit_status failed "DISCORD_APPLICATION_ID env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
|
||||
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/discord.ts ] && return 0
|
||||
! grep -q "^import './discord.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
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
|
||||
echo "import './discord.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
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 credentials. auto.ts validates before this point, so bad values here
|
||||
# would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
|
||||
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
|
||||
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
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 Discord adapter a moment to finish gateway handshake before
|
||||
# init-first-agent attempts delivery.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
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
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Step: auth — Verify or register an Anthropic credential in OneCLI.
|
||||
*
|
||||
* Modes:
|
||||
* --check (default) Verify an Anthropic secret exists.
|
||||
* --create --value <token> Create an Anthropic secret. Errors if one
|
||||
* already exists unless --force is passed.
|
||||
*
|
||||
* The actual user-facing prompt (subscription vs API key, paste the token)
|
||||
* stays in the /new-setup SKILL.md. This step is just the machine side:
|
||||
* it calls `onecli secrets list` / `onecli secrets create` and emits a
|
||||
* structured status block. The token value is never logged.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
|
||||
|
||||
interface Args {
|
||||
mode: 'check' | 'create';
|
||||
value?: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function childEnv(): NodeJS.ProcessEnv {
|
||||
const parts = [LOCAL_BIN];
|
||||
if (process.env.PATH) parts.push(process.env.PATH);
|
||||
return { ...process.env, PATH: parts.join(path.delimiter) };
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Args {
|
||||
let mode: 'check' | 'create' = 'check';
|
||||
let value: string | undefined;
|
||||
let force = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
const val = args[i + 1];
|
||||
switch (key) {
|
||||
case '--check':
|
||||
mode = 'check';
|
||||
break;
|
||||
case '--create':
|
||||
mode = 'create';
|
||||
break;
|
||||
case '--value':
|
||||
value = val;
|
||||
i++;
|
||||
break;
|
||||
case '--force':
|
||||
force = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'create' && !value) {
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_value_for_create',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { mode, value, force };
|
||||
}
|
||||
|
||||
interface OnecliSecret {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hostPattern: string | null;
|
||||
}
|
||||
|
||||
function listSecrets(): OnecliSecret[] {
|
||||
const out = execFileSync('onecli', ['secrets', 'list'], {
|
||||
encoding: 'utf-8',
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const parsed = JSON.parse(out) as { data?: unknown };
|
||||
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
|
||||
}
|
||||
|
||||
function findAnthropicSecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
|
||||
return secrets.find((s) => s.type === 'anthropic');
|
||||
}
|
||||
|
||||
function createAnthropicSecret(value: string): void {
|
||||
// `value` is a credential — do not log it, do not echo, do not pass through a shell.
|
||||
execFileSync(
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'anthropic',
|
||||
'--value',
|
||||
value,
|
||||
'--host-pattern',
|
||||
'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { mode, value, force } = parseArgs(args);
|
||||
|
||||
let secrets: OnecliSecret[];
|
||||
try {
|
||||
secrets = listSecrets();
|
||||
} catch (err) {
|
||||
log.error('onecli secrets list failed', { err });
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_list_failed',
|
||||
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = findAnthropicSecret(secrets);
|
||||
|
||||
if (mode === 'check') {
|
||||
emitStatus('AUTH', {
|
||||
SECRET_PRESENT: !!existing,
|
||||
ANTHROPIC_OK: !!existing,
|
||||
STATUS: existing ? 'success' : 'missing',
|
||||
...(existing ? { SECRET_NAME: existing.name, SECRET_ID: existing.id } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// mode === 'create'
|
||||
if (existing && !force) {
|
||||
emitStatus('AUTH', {
|
||||
SECRET_PRESENT: true,
|
||||
STATUS: 'skipped',
|
||||
REASON: 'anthropic_secret_already_exists',
|
||||
SECRET_NAME: existing.name,
|
||||
SECRET_ID: existing.id,
|
||||
HINT: 'Re-run with --force to replace, or delete the existing secret first.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
createAnthropicSecret(value!);
|
||||
} catch (err) {
|
||||
const e = err as { stderr?: string | Buffer; status?: number };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8') ?? '';
|
||||
log.error('onecli secrets create failed', { status: e.status, stderr });
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_create_failed',
|
||||
EXIT_CODE: e.status ?? -1,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Re-verify
|
||||
const updated = findAnthropicSecret(listSecrets());
|
||||
|
||||
emitStatus('AUTH', {
|
||||
SECRET_PRESENT: !!updated,
|
||||
ANTHROPIC_OK: !!updated,
|
||||
CREATED: true,
|
||||
STATUS: updated ? 'success' : 'failed',
|
||||
...(updated ? { SECRET_NAME: updated.name, SECRET_ID: updated.id } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
}
|
||||
+614
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* 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|first-chat)
|
||||
*
|
||||
* 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 { runDiscordChannel } from './channels/discord.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { brandBold, brandChip, dimWrap, wrapForGutter } 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(
|
||||
dimWrap(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
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(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
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: 'Bringing your assistant online…',
|
||||
done: 'Assistant wired up.',
|
||||
},
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
'cli-agent',
|
||||
"Couldn't bring your assistant online.",
|
||||
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
|
||||
);
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
await runFirstChat();
|
||||
} else {
|
||||
renderPingFailureNote(ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
const choice = await askChannelChoice();
|
||||
if (choice === 'telegram') {
|
||||
await runTelegramChannel(displayName!);
|
||||
} else if (choice === 'discord') {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
notes.push(
|
||||
wrapForGutter(
|
||||
[
|
||||
'• Your NanoClaw service is running from a different folder on this machine.',
|
||||
' Point it at this checkout with:',
|
||||
' launchctl bootout gui/$(id -u)/com.nanoclaw',
|
||||
' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
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."));
|
||||
}
|
||||
|
||||
// ─── first-chat step ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Round-trip ping against the CLI socket before we ask the user to chat.
|
||||
* Renders its own spinner with elapsed time because a cold-start container
|
||||
* boot can take 30–60s — the elapsed counter is the difference between
|
||||
* "patient" and "is this hung?". Returns the raw result so the caller can
|
||||
* branch between the chat loop (ok) and a diagnostic note (anything else).
|
||||
*/
|
||||
async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(label);
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.message(`${label} ${k.dim(`(${elapsed}s)`)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
if (result === 'ok') {
|
||||
s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderPingFailureNote(result: PingResult): void {
|
||||
const body =
|
||||
result === 'socket_error'
|
||||
? [
|
||||
wrapForGutter(
|
||||
"The NanoClaw service isn't listening on its local socket. Try restarting it, then chat with `pnpm run chat hi`:",
|
||||
6,
|
||||
),
|
||||
'',
|
||||
k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'),
|
||||
k.dim(' Linux: systemctl --user restart nanoclaw'),
|
||||
].join('\n')
|
||||
: wrapForGutter(
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
6,
|
||||
);
|
||||
p.note(body, 'Skipping the first chat');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
||||
* the same Unix-socket path the ping just exercised, so output streams
|
||||
* back inline as the agent replies. An empty input ends the loop.
|
||||
*/
|
||||
async function runFirstChat(): Promise<void> {
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Say something to your assistant',
|
||||
placeholder: 'press Enter with nothing to continue',
|
||||
}),
|
||||
);
|
||||
const text = ((answer as string | undefined) ?? '').trim();
|
||||
if (!text) return;
|
||||
await sendChatMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
function sendChatMessage(message: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// `pnpm --silent` suppresses the `> nanoclaw@… chat` preamble so the
|
||||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||||
// with spaces on the far side.
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['--silent', 'run', 'chat', ...message.split(/\s+/)],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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' | 'discord' | '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: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
return choice as 'telegram' | 'discord' | '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 wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
p.intro(
|
||||
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always include the wordmark inside the clack intro line. When bash ran
|
||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||
// above us; the small repeat is worth it to keep the brand anchored at
|
||||
// the visible top of the clack session once the bash output scrolls away.
|
||||
p.intro(`${wordmark} ${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,455 @@
|
||||
/**
|
||||
* Discord channel flow for setup:auto.
|
||||
*
|
||||
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
|
||||
* bot?" through the welcome DM:
|
||||
*
|
||||
* 1. Ask if they have a bot already; walk them through Dev Portal creation
|
||||
* if not
|
||||
* 2. Paste the bot token (clack password) — format-validated
|
||||
* 3. GET /users/@me to confirm the token and resolve bot username
|
||||
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
|
||||
* (public key), and owner — no separate paste needed in the common case
|
||||
* 5. Confirm owner identity (falls back to a manual user-id prompt with
|
||||
* Developer Mode instructions if declined or if the app is team-owned)
|
||||
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
|
||||
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
|
||||
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
|
||||
* 9. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
|
||||
* DM through the normal delivery path
|
||||
*
|
||||
* 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 { spawn } from 'child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
|
||||
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
|
||||
// + Read Message History (0x10000) = 100416.
|
||||
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
|
||||
const INVITE_PERMISSIONS = '100416';
|
||||
|
||||
interface AppInfo {
|
||||
applicationId: string;
|
||||
publicKey: string;
|
||||
owner: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
if (!(await askHasBotToken())) {
|
||||
await walkThroughBotCreation();
|
||||
}
|
||||
|
||||
const token = await collectDiscordToken();
|
||||
const botUsername = await validateDiscordToken(token);
|
||||
const app = await fetchApplicationInfo(token);
|
||||
|
||||
const ownerUserId = await resolveOwnerUserId(app.owner);
|
||||
|
||||
await promptInviteBot(app.applicationId, botUsername);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'discord-install',
|
||||
'bash',
|
||||
['setup/add-discord.sh'],
|
||||
{
|
||||
running: `Connecting Discord to @${botUsername}…`,
|
||||
done: 'Discord connected.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
DISCORD_BOT_TOKEN: token,
|
||||
DISCORD_APPLICATION_ID: app.applicationId,
|
||||
DISCORD_PUBLIC_KEY: app.publicKey,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_USERNAME: botUsername,
|
||||
APPLICATION_ID: app.applicationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'discord-install',
|
||||
"Couldn't connect Discord.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||
const platformId = `discord:@me:${dmChannelId}`;
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'discord',
|
||||
'--user-id', `discord:${ownerUserId}`,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to your Discord DMs…`,
|
||||
done: `${agentName} is ready. Check Discord for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'discord',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasBotToken(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Do you already have a Discord bot?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
return answer === 'yes';
|
||||
}
|
||||
|
||||
async function walkThroughBotCreation(): Promise<void> {
|
||||
const url = 'https://discord.com/developers/applications';
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
|
||||
'',
|
||||
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
|
||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||
' 3. On the same tab, enable "Message Content Intent"',
|
||||
' (under Privileged Gateway Intents)',
|
||||
'',
|
||||
k.dim(`Opening ${url} …`),
|
||||
].join('\n'),
|
||||
'Create a Discord bot',
|
||||
);
|
||||
openUrl(url);
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Got your bot token?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
// Discord bot tokens are base64url segments separated by dots.
|
||||
// Be lenient on length; the real check is /users/@me.
|
||||
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
|
||||
return "That doesn't look like a Discord bot token";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'discord_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateDiscordToken(token: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
username?: string;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (res.ok && data.username) {
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: data.username,
|
||||
BOT_ID: data.id ?? '',
|
||||
});
|
||||
return data.username;
|
||||
}
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Discord didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
'discord-validate',
|
||||
"Discord didn't accept that token.",
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
'discord-validate',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Looking up your bot application…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
verify_key?: string;
|
||||
owner?: { id: string; username: string } | null;
|
||||
team?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id || !data.verify_key) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
'discord-app-info',
|
||||
"Couldn't read your Discord application details.",
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
);
|
||||
}
|
||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
// owner is populated for solo applications; team-owned apps return a
|
||||
// team object instead and we'll fall back to a manual user-id prompt.
|
||||
const owner =
|
||||
data.owner && data.owner.id && data.owner.username
|
||||
? { id: data.owner.id, username: data.owner.username }
|
||||
: null;
|
||||
setupLog.step('discord-app-info', 'success', Date.now() - start, {
|
||||
APPLICATION_ID: data.id,
|
||||
OWNER_USERNAME: owner?.username ?? '',
|
||||
TEAM_OWNED: data.team ? 'true' : 'false',
|
||||
});
|
||||
return {
|
||||
applicationId: data.id,
|
||||
publicKey: data.verify_key,
|
||||
owner,
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
'discord-app-info',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOwnerUserId(
|
||||
owner: { id: string; username: string } | null,
|
||||
): Promise<string> {
|
||||
if (owner) {
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `Is @${owner.username} your Discord account?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (confirmed === true) {
|
||||
setupLog.userInput('discord_owner_confirmed', owner.username);
|
||||
return owner.id;
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
|
||||
);
|
||||
}
|
||||
return await promptForUserIdWithDevMode();
|
||||
}
|
||||
|
||||
async function promptForUserIdWithDevMode(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"To get your Discord user ID:",
|
||||
'',
|
||||
' 1. Open Discord → Settings (⚙️) → Advanced',
|
||||
' 2. Turn on "Developer Mode"',
|
||||
' 3. Right-click your own name/avatar → "Copy User ID"',
|
||||
].join('\n'),
|
||||
'Find your Discord user ID',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your Discord user ID',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'User ID is required';
|
||||
if (!/^\d{17,20}$/.test(t)) {
|
||||
return "That doesn't look like a Discord user ID (17-20 digits)";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const id = (answer as string).trim();
|
||||
setupLog.userInput('discord_user_id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function promptInviteBot(
|
||||
applicationId: string,
|
||||
botUsername: string,
|
||||
): Promise<void> {
|
||||
const url =
|
||||
`https://discord.com/api/oauth2/authorize` +
|
||||
`?client_id=${applicationId}` +
|
||||
`&scope=bot` +
|
||||
`&permissions=${INVITE_PERMISSIONS}`;
|
||||
|
||||
p.note(
|
||||
[
|
||||
`@${botUsername} needs to share a server with you before it can DM you.`,
|
||||
'',
|
||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||
' 2. Click "Authorize"',
|
||||
'',
|
||||
k.dim(`Opening ${url}`),
|
||||
].join('\n'),
|
||||
'Add bot to a server',
|
||||
);
|
||||
openUrl(url);
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "I've added the bot to a server",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Opening a DM channel…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/users/@me/channels`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ recipient_id: userId }),
|
||||
});
|
||||
const data = (await res.json()) as { id?: string; message?: string };
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't open a DM channel with you.",
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
);
|
||||
}
|
||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.id,
|
||||
});
|
||||
return data.id;
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
||||
function openUrl(url: string): void {
|
||||
try {
|
||||
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||
const child = spawn(cmd, [url], { stdio: 'ignore', detached: true });
|
||||
child.on('error', () => {
|
||||
// Headless / no browser / unknown command — the URL is already
|
||||
// printed in the note above, so the user can copy-paste.
|
||||
});
|
||||
child.unref();
|
||||
} catch {
|
||||
// swallow — URL is visible in the note.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
|
||||
*
|
||||
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
|
||||
* /new-setup SKILL.md can parse the result without having to read the
|
||||
* script's plain stdout.
|
||||
*
|
||||
* Args:
|
||||
* --display-name <name> (required) operator's display name
|
||||
* --agent-name <name> (optional) agent persona name, defaults to display-name
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
function parseArgs(args: string[]): {
|
||||
displayName: string;
|
||||
agentName?: string;
|
||||
} {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
const val = args[i + 1];
|
||||
switch (key) {
|
||||
case '--display-name':
|
||||
displayName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--agent-name':
|
||||
agentName = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
emitStatus('CLI_AGENT', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_display_name',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { displayName, agentName };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { displayName, agentName } = parseArgs(args);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
|
||||
|
||||
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
|
||||
if (agentName) scriptArgs.push('--agent-name', agentName);
|
||||
|
||||
log.info('Invoking init-cli-agent', { displayName, agentName });
|
||||
|
||||
try {
|
||||
execFileSync('pnpm', scriptArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string; status?: number };
|
||||
log.error('init-cli-agent failed', {
|
||||
status: e.status,
|
||||
stdout: e.stdout,
|
||||
stderr: e.stderr,
|
||||
});
|
||||
emitStatus('CLI_AGENT', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'init_script_failed',
|
||||
EXIT_CODE: e.status ?? -1,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
emitStatus('CLI_AGENT', {
|
||||
DISPLAY_NAME: displayName,
|
||||
AGENT_NAME: agentName || displayName,
|
||||
CHANNEL: 'cli/local',
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
}
|
||||
+123
-63
@@ -2,15 +2,73 @@
|
||||
* 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 } {
|
||||
let runtime = '';
|
||||
// `--runtime` is still accepted for backwards compatibility with the /setup
|
||||
// skill, but `docker` is the only supported value.
|
||||
let runtime = 'docker';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runtime' && args[i + 1]) {
|
||||
runtime = args[i + 1];
|
||||
@@ -26,63 +84,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
const image = 'nanoclaw-agent:latest';
|
||||
const logFile = path.join(projectRoot, 'logs', 'setup.log');
|
||||
|
||||
if (!runtime) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: 'unknown',
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_runtime_flag',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// Validate runtime availability
|
||||
if (runtime === 'apple-container' && !commandExists('container')) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (runtime === 'docker') {
|
||||
if (!commandExists('docker')) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!['apple-container', 'docker'].includes(runtime)) {
|
||||
if (runtime !== 'docker') {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
@@ -95,9 +97,67 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const buildCmd =
|
||||
runtime === 'apple-container' ? 'container build' : 'docker build';
|
||||
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
|
||||
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,
|
||||
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';
|
||||
const runCmd = 'docker';
|
||||
|
||||
// Build-args from .env. Only INSTALL_CJK_FONTS is passed through today.
|
||||
// Keeps /setup and ./container/build.sh in sync — both read the same source.
|
||||
|
||||
@@ -21,12 +21,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const wsl = isWSL();
|
||||
const headless = isHeadless();
|
||||
|
||||
// Check Apple Container
|
||||
let appleContainer: 'installed' | 'not_found' = 'not_found';
|
||||
if (commandExists('container')) {
|
||||
appleContainer = 'installed';
|
||||
}
|
||||
|
||||
// Check Docker
|
||||
let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found';
|
||||
if (commandExists('docker')) {
|
||||
@@ -78,7 +72,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
{
|
||||
platform,
|
||||
wsl,
|
||||
appleContainer,
|
||||
docker,
|
||||
hasEnv,
|
||||
hasAuth,
|
||||
@@ -91,7 +84,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
PLATFORM: platform,
|
||||
IS_WSL: wsl,
|
||||
IS_HEADLESS: headless,
|
||||
APPLE_CONTAINER: appleContainer,
|
||||
DOCKER: docker,
|
||||
HAS_ENV: hasEnv,
|
||||
HAS_AUTH: hasAuth,
|
||||
|
||||
@@ -10,6 +10,7 @@ const STEPS: Record<
|
||||
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
||||
> = {
|
||||
timezone: () => import('./timezone.js'),
|
||||
'set-env': () => import('./set-env.js'),
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
@@ -19,6 +20,9 @@ const STEPS: Record<
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
onecli: () => import('./onecli.js'),
|
||||
auth: () => import('./auth.js'),
|
||||
'cli-agent': () => import('./cli-agent.js'),
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the Claude Code CLI on the host via the official native installer.
|
||||
# Invoked from setup/register-claude-token.sh when the user picks the
|
||||
# subscription auth path and `claude` is missing. The other two auth paths
|
||||
# (paste OAuth token, paste API key) don't need the CLI, so this runs on
|
||||
# demand rather than up front.
|
||||
#
|
||||
# The native installer is Node-independent (downloads a prebuilt binary to
|
||||
# ~/.local/bin) and is the path Anthropic documents. This matches the
|
||||
# pattern used by install-docker.sh / install-node.sh: the script itself is
|
||||
# the allowlisted unit; the curl | bash pipe lives inside it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_CLAUDE ==="
|
||||
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: curl not available."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STEP: claude-native-install"
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
# Native installer writes to ~/.local/bin and appends a PATH line to the
|
||||
# user's rc file; that doesn't help this session, so put it on PATH now.
|
||||
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
hash -r 2>/dev/null || true
|
||||
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: claude not found on PATH after install."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/discord package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/discord.ts ]] || needs_install=true
|
||||
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
printf "import './discord.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+56
@@ -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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/gchat package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/gchat.ts ]] || needs_install=true
|
||||
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
printf "import './gchat.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-github — bundles the preflight + install commands
|
||||
# from the /add-github skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the GitHub adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/github package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_GITHUB ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/github.ts ]] || needs_install=true
|
||||
grep -q "import './github.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/github"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/github ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/github.ts > src/channels/github.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './github.js';" src/channels/index.ts; then
|
||||
printf "import './github.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned chat-adapter-imessage package;
|
||||
# builds. Local vs remote mode pick stays in the skill — this script only
|
||||
# handles the deterministic install. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/imessage.ts ]] || needs_install=true
|
||||
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
|
||||
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
|
||||
printf "import './imessage.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
|
||||
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
|
||||
# onNewMention handler never fires — the bridge needs a catchAll path);
|
||||
# installs the pinned @chat-adapter/linear package; builds. All steps are
|
||||
# safe to re-run.
|
||||
#
|
||||
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
|
||||
# (current trunk signature requires the arg). The /add-linear SKILL's
|
||||
# snippet omits the arg — this script uses the full signature so TypeScript
|
||||
# builds cleanly.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/linear.ts ]] || needs_install=true
|
||||
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
|
||||
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './linear.js';" src/channels/index.ts; then
|
||||
printf "import './linear.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-field"
|
||||
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
|
||||
in_iface && /^\}/ && !inserted {
|
||||
print " /**"
|
||||
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
|
||||
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
|
||||
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
|
||||
print " */"
|
||||
print " catchAll?: boolean;"
|
||||
inserted = 1
|
||||
in_iface = 0
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-handler"
|
||||
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/ \/\/ DMs — apply engage rules too/ && !inserted {
|
||||
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
|
||||
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
|
||||
print " if (config.catchAll) {"
|
||||
print " chat.onNewMessage(/.*/, async (thread, message) => {"
|
||||
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
|
||||
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
|
||||
print " await thread.subscribe();"
|
||||
print " });"
|
||||
print " }"
|
||||
print ""
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-matrix — bundles the preflight + install commands
|
||||
# from the /add-matrix skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Matrix adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @beeper/chat-adapter-matrix
|
||||
# package; patches the adapter's published dist so its matrix-js-sdk/lib
|
||||
# imports carry .js extensions (required under Node 22 strict ESM); builds.
|
||||
# All steps are safe to re-run — re-run this script after any pnpm install
|
||||
# that touches the adapter.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_MATRIX ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/matrix.ts ]] || needs_install=true
|
||||
grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true
|
||||
[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './matrix.js';" src/channels/index.ts; then
|
||||
printf "import './matrix.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
|
||||
echo "STEP: patch-esm-extensions"
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
|
||||
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
|
||||
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
|
||||
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
|
||||
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
|
||||
));
|
||||
console.log("Patched", f);
|
||||
'
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+54
@@ -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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-resend — bundles the preflight + install commands
|
||||
# from the /add-resend skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Resend adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @resend/chat-sdk-adapter
|
||||
# package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_RESEND ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/resend.ts ]] || needs_install=true
|
||||
grep -q "import './resend.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@resend/chat-sdk-adapter"' package.json || needs_install=true
|
||||
[[ -d node_modules/@resend/chat-sdk-adapter ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './resend.js';" src/channels/index.ts; then
|
||||
printf "import './resend.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-slack — bundles the preflight + install commands
|
||||
# from the /add-slack skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Slack adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/slack package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/slack.ts ]] || needs_install=true
|
||||
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
printf "import './slack.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-teams — bundles the preflight + install commands
|
||||
# from the /add-teams skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Teams adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/teams package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_TEAMS ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/teams.ts ]] || needs_install=true
|
||||
grep -q "import './teams.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/teams"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/teams ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
printf "import './teams.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-telegram — bundles the preflight + install commands
|
||||
# from the /add-telegram skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials and pairing.
|
||||
#
|
||||
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
|
||||
# step in from the `channels` branch; appends the self-registration import;
|
||||
# registers the `pair-telegram` entry in the setup STEPS map; installs the
|
||||
# pinned @chat-adapter/telegram package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_TELEGRAM ==="
|
||||
|
||||
CHANNEL_FILES=(
|
||||
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
|
||||
setup/pair-telegram.ts
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
[[ -f "$f" ]] || needs_install=true
|
||||
done
|
||||
grep -q "import './telegram.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q "'pair-telegram':" setup/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/telegram"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/telegram ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './telegram.js';" src/channels/index.ts; then
|
||||
printf "import './telegram.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: register-setup-step"
|
||||
if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
awk '
|
||||
{ print }
|
||||
/register: \(\) => import/ && !inserted {
|
||||
print " '\''pair-telegram'\'': () => import('\''./pair-telegram.js'\''),"
|
||||
inserted = 1
|
||||
}
|
||||
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-webex — bundles the preflight + install commands
|
||||
# from the /add-webex skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Webex adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @bitbasti/chat-adapter-webex
|
||||
# package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_WEBEX ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/webex.ts ]] || needs_install=true
|
||||
grep -q "import './webex.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@bitbasti/chat-adapter-webex"' package.json || needs_install=true
|
||||
[[ -d node_modules/@bitbasti/chat-adapter-webex ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './webex.js';" src/channels/index.ts; then
|
||||
printf "import './webex.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
|
||||
# commands from the /add-whatsapp-cloud skill into one idempotent script so
|
||||
# /new-setup can run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_WHATSAPP_CLOUD ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/whatsapp-cloud.ts ]] || needs_install=true
|
||||
grep -q "import './whatsapp-cloud.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/whatsapp"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/whatsapp ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
|
||||
printf "import './whatsapp-cloud.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp — bundles the preflight + install commands
|
||||
# from the /add-whatsapp skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to QR/pairing-code auth.
|
||||
#
|
||||
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
|
||||
# setup steps in from the `channels` branch; appends the self-registration
|
||||
# import; registers `groups` and `whatsapp-auth` entries in the setup STEPS
|
||||
# map; installs the pinned @whiskeysockets/baileys + qrcode + pino packages;
|
||||
# builds. All steps are safe to re-run. QR/pairing-code authentication
|
||||
# stays in the skill — this script only handles the deterministic install.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_WHATSAPP ==="
|
||||
|
||||
CHANNEL_FILES=(
|
||||
src/channels/whatsapp.ts
|
||||
setup/whatsapp-auth.ts
|
||||
setup/groups.ts
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
[[ -f "$f" ]] || needs_install=true
|
||||
done
|
||||
grep -q "import './whatsapp.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q "groups: " setup/index.ts || needs_install=true
|
||||
grep -q "'whatsapp-auth':" setup/index.ts || needs_install=true
|
||||
grep -q '"@whiskeysockets/baileys"' package.json || needs_install=true
|
||||
grep -q '"qrcode"' package.json || needs_install=true
|
||||
grep -q '"pino"' package.json || needs_install=true
|
||||
[[ -d node_modules/@whiskeysockets/baileys ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './whatsapp.js';" src/channels/index.ts; then
|
||||
printf "import './whatsapp.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: register-setup-steps"
|
||||
if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
awk '
|
||||
{ print }
|
||||
/register: \(\) => import/ && !inserted {
|
||||
print " groups: () => import('\''./groups.js'\''),"
|
||||
print " '\''whatsapp-auth'\'': () => import('\''./whatsapp-auth.js'\''),"
|
||||
inserted = 1
|
||||
}
|
||||
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Round-trip check against the CLI Unix socket.
|
||||
*
|
||||
* Shared by `setup/verify.ts` (end-of-run health check) and `setup/auto.ts`
|
||||
* (confirm the freshly-wired agent actually responds before prompting the
|
||||
* user to chat with it).
|
||||
*
|
||||
* Exit-code contract follows `scripts/chat.ts`:
|
||||
* 0 → got a reply on stdout
|
||||
* 2 → socket unreachable (service not running or wrong checkout)
|
||||
* 3 → no reply before chat.ts's own 120s hard stop
|
||||
* This wrapper also guards with its own timeout in case chat.ts hangs.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
|
||||
|
||||
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
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');
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,102 @@
|
||||
/**
|
||||
* 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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text so it fits inside clack's gutter without the terminal's soft
|
||||
* wrap breaking the `│ …` bar on long lines. Works on a single string with
|
||||
* embedded `\n`s; each logical line is wrapped independently.
|
||||
*
|
||||
* The `gutter` argument is the total horizontal overhead clack adds for
|
||||
* the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix;
|
||||
* 6-ish for `p.note`'s box). Caller picks it; we just subtract from
|
||||
* `process.stdout.columns` and hard-wrap at word boundaries.
|
||||
*/
|
||||
export function wrapForGutter(text: string, gutter: number): string {
|
||||
const cols = process.stdout.columns ?? 80;
|
||||
const width = Math.max(30, cols - gutter);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => wrapLine(line, width))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
|
||||
* because clack resets styling at each line break when rendering
|
||||
* multi-line log content — a single outer dim envelope only colors the
|
||||
* first line. Applying dim per-line gives each wrapped row its own
|
||||
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
|
||||
*/
|
||||
export function dimWrap(text: string, gutter: number): string {
|
||||
return wrapForGutter(text, gutter)
|
||||
.split('\n')
|
||||
.map((line) => k.dim(line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
function visibleLength(s: string): number {
|
||||
return s.replace(ANSI_RE, '').length;
|
||||
}
|
||||
|
||||
function wrapLine(line: string, width: number): string {
|
||||
if (visibleLength(line) <= width) return line;
|
||||
const words = line.split(' ');
|
||||
const rows: string[] = [];
|
||||
let cur = '';
|
||||
let curLen = 0;
|
||||
for (const word of words) {
|
||||
const wLen = visibleLength(word);
|
||||
if (curLen === 0) {
|
||||
cur = word;
|
||||
curLen = wLen;
|
||||
} else if (curLen + 1 + wLen <= width) {
|
||||
cur += ' ' + word;
|
||||
curLen += 1 + wLen;
|
||||
} else {
|
||||
rows.push(cur);
|
||||
cur = word;
|
||||
curLen = wLen;
|
||||
}
|
||||
}
|
||||
if (cur) rows.push(cur);
|
||||
return rows.join('\n');
|
||||
}
|
||||
+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`;
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Step: onecli — Install + configure the OneCLI gateway and CLI.
|
||||
*
|
||||
* Aggregates what the old /setup + /init-onecli skills ran as loose shell
|
||||
* commands. Idempotent: skips install if `onecli` already works, and safely
|
||||
* re-applies PATH, api-host, and .env updates.
|
||||
*
|
||||
* Emits ONECLI_URL so /new-setup SKILL.md can forward it downstream (e.g. as
|
||||
* ${ONECLI_URL} in status messages). Polls /health to give downstream steps
|
||||
* (auth, service) a ready gateway.
|
||||
*/
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
|
||||
|
||||
function childEnv(): NodeJS.ProcessEnv {
|
||||
const parts = [LOCAL_BIN];
|
||||
if (process.env.PATH) parts.push(process.env.PATH);
|
||||
return { ...process.env, PATH: parts.join(path.delimiter) };
|
||||
}
|
||||
|
||||
function onecliVersion(): string | null {
|
||||
try {
|
||||
return execFileSync('onecli', ['version'], {
|
||||
encoding: 'utf-8',
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlFromOutput(output: string): string | null {
|
||||
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function ensureShellProfilePath(): void {
|
||||
const home = os.homedir();
|
||||
const line = 'export PATH="$HOME/.local/bin:$PATH"';
|
||||
for (const profile of [path.join(home, '.bashrc'), path.join(home, '.zshrc')]) {
|
||||
try {
|
||||
const content = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : '';
|
||||
if (!content.includes('.local/bin')) {
|
||||
fs.appendFileSync(profile, `\n${line}\n`);
|
||||
log.info('Added ~/.local/bin to PATH in shell profile', { profile });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Could not update shell profile', { profile, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
if (/^ONECLI_URL=/m.test(content)) {
|
||||
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
}
|
||||
|
||||
function installOnecli(): { stdout: string; ok: boolean } {
|
||||
// OneCLI's own install script handles gateway + CLI + PATH.
|
||||
// We run the two canonical installers in sequence and capture stdout so
|
||||
// we can extract the printed URL as a fallback to `onecli config get`.
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { stdout, ok: true };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string };
|
||||
log.error('OneCLI install failed', { stderr: e.stderr });
|
||||
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${url}/api/health`);
|
||||
if (res.ok) return true;
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
ensureShellProfilePath();
|
||||
|
||||
log.info('Installing OneCLI gateway and CLI');
|
||||
const res = installOnecli();
|
||||
if (!res.ok) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'install_failed',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
if (!onecliVersion()) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_not_on_path_after_install',
|
||||
HINT: 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"` and retry.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const url = extractUrlFromOutput(res.stdout);
|
||||
if (!url) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'could_not_resolve_api_host',
|
||||
HINT: 'Inspect logs/setup.log for the install output.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('onecli', ['config', 'set', 'api-host', url], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli config set api-host failed', { err });
|
||||
}
|
||||
|
||||
writeEnvOnecliUrl(url);
|
||||
log.info('Wrote ONECLI_URL to .env', { url });
|
||||
|
||||
const healthy = await pollHealth(url, 15000);
|
||||
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
ONECLI_URL: url,
|
||||
HEALTHY: healthy,
|
||||
// Install succeeded regardless — a failed health poll often just means
|
||||
// the endpoint is auth-gated or the gateway hasn't finished warming up.
|
||||
// The next step (auth) will surface a genuinely broken gateway via
|
||||
// `onecli secrets list`, so don't trigger rescue attempts from here.
|
||||
STATUS: 'success',
|
||||
...(healthy
|
||||
? {}
|
||||
: {
|
||||
HEALTH_HINT:
|
||||
'Health poll returned non-ok within 15s — likely auth-gated. Proceed to the auth step; it will surface a real outage.',
|
||||
}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
}
|
||||
+41
-41
@@ -1,17 +1,23 @@
|
||||
/**
|
||||
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
||||
* operator to send `@botname CODE` from the chat they want to register.
|
||||
* operator to send the code from the chat they want to register.
|
||||
*
|
||||
* On success, prints platformId / isGroup / pairedUserId / intent. The caller
|
||||
* (skill) can then wire the chat to an agent group (e.g. via /init-first-agent
|
||||
* or setup --step register). telegram.ts's inbound interceptor has already
|
||||
* upserted the paired user and granted owner if no owner existed yet.
|
||||
* 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.
|
||||
*
|
||||
* The service must already be running so the telegram adapter is polling.
|
||||
* 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 { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
@@ -19,24 +25,25 @@ import {
|
||||
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++) {
|
||||
switch (args[i]) {
|
||||
case '--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}`);
|
||||
}
|
||||
break;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,20 +58,18 @@ function intentToString(intent: PairingIntent): string {
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const intent = parseArgs(args);
|
||||
|
||||
// Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly
|
||||
// required for the pairing primitive itself, but the inbound interceptor
|
||||
// (running in the live service) needs it. Touch it here so a fresh install
|
||||
// doesn't blow up on the first match.
|
||||
// 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_ISSUED', {
|
||||
emitStatus('PAIR_TELEGRAM_CODE', {
|
||||
CODE: record.code,
|
||||
INTENT: intentToString(intent),
|
||||
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`,
|
||||
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
|
||||
REASON: 'initial',
|
||||
});
|
||||
|
||||
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
||||
@@ -72,13 +77,11 @@ export async function run(args: string[]): Promise<void> {
|
||||
const consumed = await waitForPairing(record.code, {
|
||||
onAttempt: (a) => {
|
||||
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
|
||||
EXPECTED_CODE: record.code,
|
||||
RECEIVED_CODE: a.candidate,
|
||||
PLATFORM_ID: a.platformId,
|
||||
AT: a.at,
|
||||
CANDIDATE: a.candidate,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'success',
|
||||
CODE: record.code,
|
||||
@@ -95,20 +98,17 @@ export async function run(args: string[]): Promise<void> {
|
||||
const invalidated = /invalidated by wrong code/.test(message);
|
||||
if (invalidated && regen < MAX_REGENERATIONS) {
|
||||
record = await createPairing(intent);
|
||||
emitStatus('PAIR_TELEGRAM_NEW_CODE', {
|
||||
emitStatus('PAIR_TELEGRAM_CODE', {
|
||||
CODE: record.code,
|
||||
INTENT: intentToString(intent),
|
||||
REASON: 'previous code invalidated by wrong attempt',
|
||||
REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1,
|
||||
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`,
|
||||
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
|
||||
REASON: 'regenerated',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reason = invalidated ? 'max-regenerations-exceeded' : message;
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'failed',
|
||||
CODE: record.code,
|
||||
ERROR: invalidated ? 'max-regenerations-exceeded' : message,
|
||||
ERROR: reason,
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/bash
|
||||
# Setup step: probe — single upfront parallel-ish scan that snapshots every
|
||||
# prerequisite and dependency for /new-setup's dynamic context injection.
|
||||
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
|
||||
# the current system state before generating its first response.
|
||||
#
|
||||
# Pure bash by design: this runs BEFORE setup.sh has installed Node, pnpm, and
|
||||
# node_modules, so it cannot rely on any Node-based tooling. Every field below
|
||||
# is computed from POSIX utilities + grep/awk/curl.
|
||||
#
|
||||
# This is a routing aid, NOT a replacement for per-step idempotency checks.
|
||||
# Each step keeps its own checks; probe tells the skill which steps to skip.
|
||||
#
|
||||
# Keep fast (<2s total). All probes swallow their own errors and report a
|
||||
# neutral state rather than failing the whole scan.
|
||||
set -u
|
||||
|
||||
START_S=$(date +%s)
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
AGENT_IMAGE="nanoclaw-agent:latest"
|
||||
|
||||
export PATH="$LOCAL_BIN:$PATH"
|
||||
|
||||
command_exists() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# Best-effort 2s timeout; falls back to no timeout on macOS if `timeout` isn't
|
||||
# installed (the probed commands are all expected to return fast anyway).
|
||||
with_timeout() {
|
||||
if command_exists timeout; then timeout 2 "$@"
|
||||
elif command_exists gtimeout; then gtimeout 2 "$@"
|
||||
else "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
trim() {
|
||||
local s="$1"
|
||||
s="${s#"${s%%[![:space:]]*}"}"
|
||||
s="${s%"${s##*[![:space:]]}"}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
read_env_var() {
|
||||
local name="$1"
|
||||
local envfile="$PROJECT_ROOT/.env"
|
||||
[[ -f "$envfile" ]] || return 0
|
||||
local line
|
||||
line=$(grep -E "^${name}=" "$envfile" 2>/dev/null | head -n1) || return 0
|
||||
[[ -z "$line" ]] && return 0
|
||||
local val="${line#*=}"
|
||||
val="${val%\"}"; val="${val#\"}"
|
||||
val="${val%\'}"; val="${val#\'}"
|
||||
trim "$val"
|
||||
}
|
||||
|
||||
probe_os() {
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
Darwin) echo "macos" ;;
|
||||
Linux)
|
||||
if [[ -r /proc/version ]] && grep -qEi "microsoft|wsl" /proc/version; then
|
||||
echo "wsl"
|
||||
else
|
||||
echo "linux"
|
||||
fi
|
||||
;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
probe_host_deps() {
|
||||
local node_modules="$PROJECT_ROOT/node_modules"
|
||||
local native="$node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
# `better-sqlite3`'s compiled native binding is the canonical proof that
|
||||
# `pnpm install` ran AND the native build step succeeded.
|
||||
if [[ -d "$node_modules" && -f "$native" ]]; then
|
||||
echo "ok"
|
||||
else
|
||||
echo "missing"
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets DOCKER_STATUS and IMAGE_PRESENT as globals.
|
||||
probe_docker() {
|
||||
DOCKER_STATUS="not_found"
|
||||
IMAGE_PRESENT="false"
|
||||
command_exists docker || return 0
|
||||
if ! with_timeout docker info >/dev/null 2>&1; then
|
||||
DOCKER_STATUS="installed_not_running"
|
||||
return 0
|
||||
fi
|
||||
DOCKER_STATUS="running"
|
||||
if with_timeout docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
|
||||
IMAGE_PRESENT="true"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_onecli_url() {
|
||||
local url
|
||||
url=$(read_env_var ONECLI_URL)
|
||||
if [[ -n "$url" ]]; then
|
||||
printf '%s' "$url"
|
||||
return
|
||||
fi
|
||||
command_exists onecli || return 0
|
||||
local out
|
||||
out=$(with_timeout onecli config get api-host 2>/dev/null) || return 0
|
||||
# Minimal JSON extract: {"value":"http..."} — avoid hard dep on jq
|
||||
if [[ "$out" =~ \"value\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
||||
printf '%s' "${BASH_REMATCH[1]}"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_onecli_status() {
|
||||
local url="$1"
|
||||
if ! command_exists onecli && [[ ! -x "$LOCAL_BIN/onecli" ]]; then
|
||||
echo "not_found"; return
|
||||
fi
|
||||
if [[ -z "$url" ]]; then
|
||||
echo "installed_not_healthy"; return
|
||||
fi
|
||||
if command_exists curl \
|
||||
&& curl -fsS --max-time 2 "${url}/api/health" >/dev/null 2>&1; then
|
||||
echo "healthy"
|
||||
else
|
||||
echo "installed_not_healthy"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_anthropic_secret() {
|
||||
command_exists onecli || { echo "false"; return; }
|
||||
local out
|
||||
out=$(with_timeout onecli secrets list 2>/dev/null) || { echo "false"; return; }
|
||||
if echo "$out" | grep -Eq '"type"[[:space:]]*:[[:space:]]*"anthropic"'; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_service_status() {
|
||||
local platform="$1"
|
||||
case "$platform" in
|
||||
macos)
|
||||
command_exists launchctl || { echo "not_configured"; return; }
|
||||
local line
|
||||
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
|
||||
echo "not_configured"; return; }
|
||||
local pid
|
||||
pid=$(echo "$line" | awk '{print $1}')
|
||||
if [[ -n "$pid" && "$pid" != "-" ]]; then
|
||||
echo "running"
|
||||
else
|
||||
echo "stopped"
|
||||
fi
|
||||
;;
|
||||
linux|wsl)
|
||||
command_exists systemctl || { echo "not_configured"; return; }
|
||||
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
|
||||
echo "running"
|
||||
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
|
||||
echo "stopped"
|
||||
else
|
||||
echo "not_configured"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "not_configured"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
probe_display_name() {
|
||||
local platform="$1"
|
||||
local reject_re='^(|root)$'
|
||||
local name
|
||||
|
||||
if command_exists git; then
|
||||
name=$(trim "$(git config --global user.name 2>/dev/null)")
|
||||
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
|
||||
printf '%s' "$name"; return
|
||||
fi
|
||||
fi
|
||||
|
||||
local user="${USER:-$(id -un 2>/dev/null)}"
|
||||
|
||||
case "$platform" in
|
||||
macos)
|
||||
if command_exists id; then
|
||||
name=$(trim "$(id -F "$user" 2>/dev/null)")
|
||||
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
|
||||
printf '%s' "$name"; return
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
linux|wsl)
|
||||
if command_exists getent; then
|
||||
local entry gecos
|
||||
entry=$(getent passwd "$user" 2>/dev/null)
|
||||
gecos=$(echo "$entry" | awk -F: '{print $5}')
|
||||
name=$(trim "$(echo "$gecos" | awk -F, '{print $1}')")
|
||||
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
|
||||
printf '%s' "$name"; return
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$user" && ! "$user" =~ $reject_re ]]; then
|
||||
printf '%s' "$user"
|
||||
else
|
||||
printf 'User'
|
||||
fi
|
||||
}
|
||||
|
||||
OS=$(probe_os)
|
||||
SHELL_NAME="${SHELL:-unknown}"
|
||||
HOST_DEPS=$(probe_host_deps)
|
||||
probe_docker
|
||||
ONECLI_URL_VAL=$(probe_onecli_url)
|
||||
ONECLI_STATUS=$(probe_onecli_status "$ONECLI_URL_VAL")
|
||||
if [[ "$ONECLI_STATUS" == "not_found" ]]; then
|
||||
ANTHROPIC_SECRET="false"
|
||||
else
|
||||
ANTHROPIC_SECRET=$(probe_anthropic_secret)
|
||||
fi
|
||||
SERVICE_STATUS=$(probe_service_status "$OS")
|
||||
DISPLAY_NAME=$(probe_display_name "$OS")
|
||||
|
||||
END_S=$(date +%s)
|
||||
ELAPSED_MS=$(( (END_S - START_S) * 1000 ))
|
||||
|
||||
cat <<EOF
|
||||
=== NANOCLAW SETUP: PROBE ===
|
||||
OS: ${OS}
|
||||
SHELL: ${SHELL_NAME}
|
||||
HOST_DEPS: ${HOST_DEPS}
|
||||
DOCKER: ${DOCKER_STATUS}
|
||||
IMAGE_PRESENT: ${IMAGE_PRESENT}
|
||||
ONECLI_STATUS: ${ONECLI_STATUS}
|
||||
ONECLI_URL: ${ONECLI_URL_VAL:-none}
|
||||
ANTHROPIC_SECRET: ${ANTHROPIC_SECRET}
|
||||
SERVICE_STATUS: ${SERVICE_STATUS}
|
||||
INFERRED_DISPLAY_NAME: ${DISPLAY_NAME}
|
||||
ELAPSED_MS: ${ELAPSED_MS}
|
||||
STATUS: success
|
||||
=== END ===
|
||||
EOF
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/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; }
|
||||
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
echo "Claude Code CLI not found — installing it now (needed for subscription sign-in)…"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if ! bash "$SCRIPT_DIR/install-claude.sh"; then
|
||||
echo >&2
|
||||
echo "Couldn't install the Claude Code CLI automatically." >&2
|
||||
echo "Install it manually with" >&2
|
||||
echo " curl -fsSL https://claude.ai/install.sh | bash" >&2
|
||||
echo "and re-run setup." >&2
|
||||
exit 1
|
||||
fi
|
||||
# install-claude.sh PATH additions are scoped to its own subshell; redo
|
||||
# them here so the rest of this script can see the fresh `claude` binary.
|
||||
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
hash -r 2>/dev/null || true
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
@@ -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 "<token>" [--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<void> {
|
||||
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 <KEY> is required');
|
||||
}
|
||||
if (valueIdx === -1 || args[valueIdx + 1] === undefined) {
|
||||
throw new Error('--value <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',
|
||||
});
|
||||
}
|
||||
+89
-18
@@ -14,6 +14,7 @@ import Database from 'better-sqlite3';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { readEnvFile } from '../src/env.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { pingCliAgent } from './lib/agent-ping.js';
|
||||
import {
|
||||
getPlatform,
|
||||
getServiceManager,
|
||||
@@ -29,19 +30,35 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
log.info('Starting verification');
|
||||
|
||||
// 1. Check service status
|
||||
let service = 'not_found';
|
||||
// 1. Check service status + detect checkout mismatch.
|
||||
//
|
||||
// Why the mismatch matters: the host binds `<DATA_DIR>/cli.sock` relative
|
||||
// to the project root it was started from. If the running service is from
|
||||
// a sibling checkout (common for developers with multiple clones), this
|
||||
// repo's `data/cli.sock` won't exist — AGENT_PING would return a
|
||||
// misleading `socket_error`. Surface the mismatch directly instead.
|
||||
let service:
|
||||
| 'not_found'
|
||||
| 'stopped'
|
||||
| 'running'
|
||||
| 'running_other_checkout' = 'not_found';
|
||||
let runningFromPath: string | null = null;
|
||||
const mgr = getServiceManager();
|
||||
|
||||
if (mgr === 'launchd') {
|
||||
try {
|
||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||
if (output.includes('com.nanoclaw')) {
|
||||
// Check if it has a PID (actually running)
|
||||
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
|
||||
if (line) {
|
||||
const pidField = line.trim().split(/\s+/)[0];
|
||||
service = pidField !== '-' && pidField ? 'running' : 'stopped';
|
||||
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
|
||||
if (line) {
|
||||
const pidField = line.trim().split(/\s+/)[0];
|
||||
if (pidField !== '-' && pidField) {
|
||||
service = 'running';
|
||||
const pid = Number(pidField);
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
runningFromPath = resolveBinaryScript(pid);
|
||||
}
|
||||
} else {
|
||||
service = 'stopped';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -52,6 +69,18 @@ export async function run(_args: string[]): Promise<void> {
|
||||
try {
|
||||
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
||||
service = 'running';
|
||||
try {
|
||||
const pidStr = execSync(
|
||||
`${prefix} show nanoclaw -p MainPID --value`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
const pid = Number(pidStr);
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
runningFromPath = resolveBinaryScript(pid);
|
||||
}
|
||||
} catch {
|
||||
// couldn't read MainPID; leave runningFromPath null
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const output = execSync(`${prefix} list-unit-files`, {
|
||||
@@ -74,26 +103,31 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (raw && Number.isInteger(pid) && pid > 0) {
|
||||
process.kill(pid, 0);
|
||||
service = 'running';
|
||||
runningFromPath = resolveBinaryScript(pid);
|
||||
}
|
||||
} catch {
|
||||
service = 'stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info('Service status', { service });
|
||||
|
||||
if (
|
||||
service === 'running' &&
|
||||
runningFromPath &&
|
||||
!isPathInside(runningFromPath, projectRoot)
|
||||
) {
|
||||
service = 'running_other_checkout';
|
||||
}
|
||||
|
||||
log.info('Service status', { service, runningFromPath });
|
||||
|
||||
// 2. Check container runtime
|
||||
let containerRuntime = 'none';
|
||||
try {
|
||||
execSync('command -v container', { stdio: 'ignore' });
|
||||
containerRuntime = 'apple-container';
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
containerRuntime = 'docker';
|
||||
} catch {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
containerRuntime = 'docker';
|
||||
} catch {
|
||||
// No runtime
|
||||
}
|
||||
// Docker not running
|
||||
}
|
||||
|
||||
// 3. Check credentials
|
||||
@@ -180,12 +214,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';
|
||||
|
||||
@@ -199,9 +243,36 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||
* error — callers should treat null as "couldn't tell" and skip the
|
||||
* mismatch check rather than flag a false positive.
|
||||
*/
|
||||
function resolveBinaryScript(pid: number): string | null {
|
||||
try {
|
||||
// BSD ps (macOS) and util-linux both honour `-o command=` (full argv,
|
||||
// no header). Node argv: "node /path/to/dist/index.js ...".
|
||||
const out = execSync(`ps -p ${pid} -o command=`, {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const tokens = out.split(/\s+/);
|
||||
const script = tokens.find((t) => /\.(js|mjs|cjs|ts)$/.test(t));
|
||||
return script ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(candidate: string, parent: string): boolean {
|
||||
const rel = path.relative(parent, candidate);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user