feat(setup): brand setup:auto with @clack/prompts + brand palette
Wraps the scripted setup flow in a branded, friendly UI. Each step runs
under a clack spinner with elapsed time; child stdout/stderr is captured
quietly and dumped only on failure. Interactive children (token paste,
Anthropic OAuth) bypass the spinner and inherit the TTY.
- intro: NanoClaw wordmark + brand-cyan accent chip, truecolor with
kleur fallback and NO_COLOR / non-TTY awareness
- pair-telegram: emits PAIR_TELEGRAM_CODE / _ATTEMPT status blocks only;
auto.ts renders clack notes + "received X — doesn't match" checkpoints
- streaming status-block parser handles mid-step events without waiting
for the child to exit
- terminal-block detection now finds any block with a STATUS field
(handles MOUNTS emitting CONFIGURE_MOUNTS, etc.) and treats 'skipped'
as a success variant with an optional friendlier label
Also fixes a latent bash bug where `$VAR…` (unbraced followed by a
multi-byte Unicode character) pulled ellipsis bytes into the variable
name lookup and tripped `set -u`. Braced `${VAR}` in add-telegram.sh
and register-claude-token.sh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,10 +24,13 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chat-adapter/telegram": "4.26.0",
|
||||||
|
"@clack/prompts": "^1.2.0",
|
||||||
"@onecli-sh/sdk": "^0.3.1",
|
"@onecli-sh/sdk": "^0.3.1",
|
||||||
"better-sqlite3": "11.10.0",
|
"better-sqlite3": "11.10.0",
|
||||||
"chat": "^4.24.0",
|
"chat": "^4.24.0",
|
||||||
"cron-parser": "5.5.0"
|
"cron-parser": "5.5.0",
|
||||||
|
"kleur": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.35.0",
|
"@eslint/js": "^9.35.0",
|
||||||
|
|||||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@chat-adapter/telegram':
|
||||||
|
specifier: 4.26.0
|
||||||
|
version: 4.26.0
|
||||||
|
'@clack/prompts':
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0
|
||||||
'@onecli-sh/sdk':
|
'@onecli-sh/sdk':
|
||||||
specifier: ^0.3.1
|
specifier: ^0.3.1
|
||||||
version: 0.3.1
|
version: 0.3.1
|
||||||
@@ -20,6 +26,9 @@ importers:
|
|||||||
cron-parser:
|
cron-parser:
|
||||||
specifier: 5.5.0
|
specifier: 5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
|
kleur:
|
||||||
|
specifier: ^4.1.5
|
||||||
|
version: 4.1.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.35.0
|
specifier: ^9.35.0
|
||||||
@@ -60,6 +69,18 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@chat-adapter/shared@4.26.0':
|
||||||
|
resolution: {integrity: sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==}
|
||||||
|
|
||||||
|
'@chat-adapter/telegram@4.26.0':
|
||||||
|
resolution: {integrity: sha512-PE2HoCQ4648VNKZTuHFanQNoYzM/niNoSbDyYlPq6VOoB5qsoo1ctR8TERyl1EfPBNexWZpSWYrrnQPr15LUfA==}
|
||||||
|
|
||||||
|
'@clack/core@1.2.0':
|
||||||
|
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
|
||||||
|
|
||||||
|
'@clack/prompts@1.2.0':
|
||||||
|
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
|
||||||
|
|
||||||
'@emnapi/core@1.9.2':
|
'@emnapi/core@1.9.2':
|
||||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||||
|
|
||||||
@@ -748,6 +769,15 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-string-truncated-width@1.2.1:
|
||||||
|
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
|
||||||
|
|
||||||
|
fast-string-width@1.1.0:
|
||||||
|
resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==}
|
||||||
|
|
||||||
|
fast-wrap-ansi@0.1.6:
|
||||||
|
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -866,6 +896,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
kleur@4.1.5:
|
||||||
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1239,6 +1273,9 @@ packages:
|
|||||||
simple-get@4.0.1:
|
simple-get@4.0.1:
|
||||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||||
|
|
||||||
|
sisteransi@1.0.5:
|
||||||
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1462,6 +1499,31 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@chat-adapter/shared@4.26.0':
|
||||||
|
dependencies:
|
||||||
|
chat: 4.26.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@chat-adapter/telegram@4.26.0':
|
||||||
|
dependencies:
|
||||||
|
'@chat-adapter/shared': 4.26.0
|
||||||
|
chat: 4.26.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@clack/core@1.2.0':
|
||||||
|
dependencies:
|
||||||
|
fast-wrap-ansi: 0.1.6
|
||||||
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
'@clack/prompts@1.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@clack/core': 1.2.0
|
||||||
|
fast-string-width: 1.1.0
|
||||||
|
fast-wrap-ansi: 0.1.6
|
||||||
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
'@emnapi/core@1.9.2':
|
'@emnapi/core@1.9.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
@@ -2105,6 +2167,16 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fast-string-truncated-width@1.2.1: {}
|
||||||
|
|
||||||
|
fast-string-width@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
fast-string-truncated-width: 1.2.1
|
||||||
|
|
||||||
|
fast-wrap-ansi@0.1.6:
|
||||||
|
dependencies:
|
||||||
|
fast-string-width: 1.1.0
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -2191,6 +2263,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -2735,6 +2809,8 @@ snapshots:
|
|||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
simple-concat: 1.0.1
|
simple-concat: 1.0.1
|
||||||
|
|
||||||
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ if need_install; then
|
|||||||
|
|
||||||
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
|
# 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.
|
# in this list — do not overwrite the local version with the channels copy.
|
||||||
echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…"
|
echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||||
for f in \
|
for f in \
|
||||||
src/channels/telegram.ts \
|
src/channels/telegram.ts \
|
||||||
src/channels/telegram-pairing.ts \
|
src/channels/telegram-pairing.ts \
|
||||||
@@ -59,7 +59,7 @@ if need_install; then
|
|||||||
}
|
}
|
||||||
'
|
'
|
||||||
|
|
||||||
echo "[add-telegram] Installing $ADAPTER_VERSION…"
|
echo "[add-telegram] Installing ${ADAPTER_VERSION}…"
|
||||||
pnpm install "$ADAPTER_VERSION"
|
pnpm install "$ADAPTER_VERSION"
|
||||||
|
|
||||||
echo "[add-telegram] Building…"
|
echo "[add-telegram] Building…"
|
||||||
|
|||||||
596
setup/auto.ts
596
setup/auto.ts
@@ -20,67 +20,249 @@
|
|||||||
* Run `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>` later
|
* Run `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>` later
|
||||||
* if autodetect is wrong (e.g. headless server with TZ=UTC).
|
* if autodetect is wrong (e.g. headless server with TZ=UTC).
|
||||||
*
|
*
|
||||||
* Anthropic credential registration runs via setup/register-claude-token.sh
|
* UI is rendered with @clack/prompts: spinners wrap each step, child output
|
||||||
* (the only step that truly requires human input — browser sign-in or a
|
* is captured quietly and only dumped on failure. Interactive children
|
||||||
* pasted token/key). Channel auth and `/manage-channels` remain separate
|
* (register-claude-token.sh, add-telegram.sh) bypass the spinner and run
|
||||||
* because they're platform-specific and typically handled via `/add-<channel>`
|
* with inherited stdio — clack resumes cleanly on the next step.
|
||||||
* and `/manage-channels` after this driver completes.
|
|
||||||
*/
|
*/
|
||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn, spawnSync } from 'child_process';
|
||||||
import { createInterface } from 'readline/promises';
|
|
||||||
|
import * as p from '@clack/prompts';
|
||||||
|
import k from 'kleur';
|
||||||
|
|
||||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
type Fields = Record<string, string>;
|
/**
|
||||||
type StepResult = { ok: boolean; fields: Fields; exitCode: number };
|
* Brand palette, pulled from assets/nanoclaw-logo.png:
|
||||||
|
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
|
||||||
|
* brand navy ≈ #171B3B — the dark logo background + outlines
|
||||||
|
* Gated on TTY + NO_COLOR so piped / CI output stays plain. Falls back to
|
||||||
|
* kleur's 16-color cyan when the terminal isn't truecolor.
|
||||||
|
*/
|
||||||
|
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
||||||
|
const TRUECOLOR =
|
||||||
|
USE_ANSI &&
|
||||||
|
(process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit');
|
||||||
|
|
||||||
function parseStatus(stdout: string): Fields {
|
const brand = (s: string): string => {
|
||||||
const out: Fields = {};
|
if (!USE_ANSI) return s;
|
||||||
let inBlock = false;
|
if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
|
||||||
for (const line of stdout.split('\n')) {
|
return k.cyan(s);
|
||||||
if (line.startsWith('=== NANOCLAW SETUP:')) {
|
};
|
||||||
inBlock = true;
|
const brandBold = (s: string): string => {
|
||||||
continue;
|
if (!USE_ANSI) return s;
|
||||||
|
if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`;
|
||||||
|
return k.bold(k.cyan(s));
|
||||||
|
};
|
||||||
|
const 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)));
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fields = Record<string, string>;
|
||||||
|
type Block = { type: string; fields: Fields };
|
||||||
|
type StepResult = {
|
||||||
|
ok: boolean;
|
||||||
|
exitCode: number;
|
||||||
|
blocks: Block[];
|
||||||
|
transcript: string;
|
||||||
|
/** The last block matching `stepName.toUpperCase()` if any. */
|
||||||
|
terminal: Block | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
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 (line.startsWith('=== END ===')) {
|
||||||
inBlock = false;
|
if (this.current) {
|
||||||
continue;
|
this.blocks.push(this.current);
|
||||||
|
this.onBlock(this.current);
|
||||||
|
this.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!inBlock) continue;
|
if (!this.current) return;
|
||||||
const idx = line.indexOf(':');
|
const colon = line.indexOf(':');
|
||||||
if (idx === -1) continue;
|
if (colon === -1) return;
|
||||||
const key = line.slice(0, idx).trim();
|
const key = line.slice(0, colon).trim();
|
||||||
const value = line.slice(idx + 1).trim();
|
const value = line.slice(colon + 1).trim();
|
||||||
if (key) out[key] = value;
|
if (key) this.current.fields[key] = value;
|
||||||
}
|
}
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runStep(name: string, extra: string[] = []): Promise<StepResult> {
|
/**
|
||||||
|
* Spawn a setup step as a child process, swallowing stdout/stderr into a
|
||||||
|
* buffer. The provided onBlock callback fires per status block as they
|
||||||
|
* parse. Returns when the child exits.
|
||||||
|
*/
|
||||||
|
function spawnStep(
|
||||||
|
stepName: string,
|
||||||
|
extra: string[],
|
||||||
|
onBlock: (block: Block) => void,
|
||||||
|
): Promise<StepResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
console.log(`\n── ${name} ────────────────────────────────────`);
|
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
|
||||||
const args = ['exec', 'tsx', 'setup/index.ts', '--step', name];
|
|
||||||
if (extra.length > 0) args.push('--', ...extra);
|
if (extra.length > 0) args.push('--', ...extra);
|
||||||
|
|
||||||
const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] });
|
const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let buf = '';
|
const stream = new StatusStream(onBlock);
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
|
||||||
const s = chunk.toString('utf-8');
|
child.stdout.on('data', (chunk: Buffer) => stream.write(chunk.toString('utf-8')));
|
||||||
buf += s;
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
process.stdout.write(s);
|
stream.transcript += chunk.toString('utf-8');
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
const fields = parseStatus(buf);
|
// Step block types don't always mirror step names (e.g. `mounts` emits
|
||||||
|
// CONFIGURE_MOUNTS, `container` emits SETUP_CONTAINER). Any block with
|
||||||
|
// a STATUS field is a terminal block; the last one wins.
|
||||||
|
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({
|
resolve({
|
||||||
ok: code === 0 && fields.STATUS === 'success',
|
ok,
|
||||||
fields,
|
|
||||||
exitCode: code ?? 1,
|
exitCode: code ?? 1,
|
||||||
|
blocks: stream.blocks,
|
||||||
|
transcript: stream.transcript,
|
||||||
|
terminal,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpinnerLabels = {
|
||||||
|
running: string;
|
||||||
|
done: string;
|
||||||
|
skipped?: string;
|
||||||
|
failed?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run a step under a clack spinner. Child output is captured; shown only on failure. */
|
||||||
|
async function runQuietStep(
|
||||||
|
stepName: string,
|
||||||
|
labels: SpinnerLabels,
|
||||||
|
extra: string[] = [],
|
||||||
|
): Promise<StepResult> {
|
||||||
|
return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an arbitrary child under a spinner, capturing its stdout/stderr. */
|
||||||
|
async function runQuietChild(
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
labels: SpinnerLabels,
|
||||||
|
): Promise<{ ok: boolean; exitCode: number; transcript: string }> {
|
||||||
|
return runUnderSpinner(labels, () => spawnQuiet(cmd, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnQuiet(
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ ok: boolean; exitCode: number; transcript: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let transcript = '';
|
||||||
|
child.stdout.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); });
|
||||||
|
child.stderr.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve({ ok: code === 0, exitCode: code ?? 1, transcript });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(msg: string, hint?: string): never {
|
||||||
|
p.log.error(msg);
|
||||||
|
if (hint) p.log.message(k.dim(hint));
|
||||||
|
p.log.message(k.dim('Logs: logs/setup.log'));
|
||||||
|
p.cancel('Setup aborted.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAnswer<T>(value: T | symbol): T {
|
||||||
|
if (p.isCancel(value)) {
|
||||||
|
p.cancel('Setup cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After installing Docker, this process's supplementary groups are still
|
* After installing Docker, this process's supplementary groups are still
|
||||||
* frozen from login — subsequent steps that talk to /var/run/docker.sock
|
* frozen from login — subsequent steps that talk to /var/run/docker.sock
|
||||||
@@ -89,7 +271,7 @@ function runStep(name: string, extra: string[] = []): Promise<StepResult> {
|
|||||||
* so the rest of the run inherits the docker group without a re-login.
|
* so the rest of the run inherits the docker group without a re-login.
|
||||||
*/
|
*/
|
||||||
function maybeReexecUnderSg(): void {
|
function maybeReexecUnderSg(): void {
|
||||||
if (process.env.NANOCLAW_REEXEC_SG === '1') return; // already re-exec'd
|
if (process.env.NANOCLAW_REEXEC_SG === '1') return;
|
||||||
if (process.platform !== 'linux') return;
|
if (process.platform !== 'linux') return;
|
||||||
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
||||||
if (info.status === 0) return;
|
if (info.status === 0) return;
|
||||||
@@ -97,10 +279,7 @@ function maybeReexecUnderSg(): void {
|
|||||||
if (!/permission denied/i.test(err)) return;
|
if (!/permission denied/i.test(err)) return;
|
||||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||||
|
|
||||||
console.log(
|
p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.');
|
||||||
'\n[setup:auto] Docker socket not accessible in current group — ' +
|
|
||||||
're-executing under `sg docker` to pick up new group membership.',
|
|
||||||
);
|
|
||||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||||
@@ -121,67 +300,121 @@ function anthropicSecretExists(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askDisplayName(fallback: string): Promise<string> {
|
function runInheritScript(cmd: string, args: string[]): Promise<number> {
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
return new Promise((resolve) => {
|
||||||
try {
|
const child = spawn(cmd, args, { stdio: 'inherit' });
|
||||||
const answer = await rl.question(
|
child.on('close', (code) => resolve(code ?? 1));
|
||||||
`\nWhat should your agents call you? [${fallback}]: `,
|
});
|
||||||
);
|
}
|
||||||
return answer.trim() || fallback;
|
|
||||||
} finally {
|
function formatCodeCard(code: string): string {
|
||||||
rl.close();
|
const spaced = code.split('').join(' ');
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
` ${brandBold(spaced)}`,
|
||||||
|
'',
|
||||||
|
k.dim(' Send these digits from Telegram to your bot.'),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPairTelegram(): Promise<StepResult> {
|
||||||
|
const s = p.spinner();
|
||||||
|
s.start('Creating pairing code…');
|
||||||
|
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) => {
|
||||||
|
if (block.type === 'PAIR_TELEGRAM_CODE') {
|
||||||
|
const reason = block.fields.REASON ?? 'initial';
|
||||||
|
if (reason === 'initial') {
|
||||||
|
stopSpinner('Pairing code ready.');
|
||||||
|
} else {
|
||||||
|
stopSpinner('Previous code invalidated. New code below.');
|
||||||
|
}
|
||||||
|
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code');
|
||||||
|
s.start('Waiting for the code from Telegram…');
|
||||||
|
spinnerActive = true;
|
||||||
|
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
||||||
|
stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`);
|
||||||
|
s.start('Waiting for the correct code…');
|
||||||
|
spinnerActive = true;
|
||||||
|
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||||
|
if (block.fields.STATUS === 'success') {
|
||||||
|
stopSpinner('Telegram paired.');
|
||||||
|
} else {
|
||||||
|
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 exited unexpectedly.', result.ok ? 0 : 1);
|
||||||
|
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askDisplayName(fallback: string): Promise<string> {
|
||||||
|
const answer = ensureAnswer(
|
||||||
|
await p.text({
|
||||||
|
message: 'What should your agents call you?',
|
||||||
|
placeholder: fallback,
|
||||||
|
defaultValue: fallback,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return (answer as string).trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askAgentName(fallback: string): Promise<string> {
|
async function askAgentName(fallback: string): Promise<string> {
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
const answer = ensureAnswer(
|
||||||
try {
|
await p.text({
|
||||||
const answer = await rl.question(
|
message: 'What should your messaging agent be called?',
|
||||||
`\nWhat should your agent be called? [${fallback}]: `,
|
placeholder: fallback,
|
||||||
);
|
defaultValue: fallback,
|
||||||
return answer.trim() || fallback;
|
}),
|
||||||
} finally {
|
);
|
||||||
rl.close();
|
return (answer as string).trim() || fallback;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
|
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
const choice = ensureAnswer(
|
||||||
try {
|
await p.select({
|
||||||
console.log('\nConnect a messaging app so you can chat from your phone?');
|
message: 'Connect a messaging app so you can chat from your phone?',
|
||||||
console.log(' 1) Telegram');
|
options: [
|
||||||
console.log(' 2) Skip — just use the CLI for now');
|
{ value: 'telegram', label: 'Telegram', hint: 'recommended' },
|
||||||
const answer = (await rl.question('Choose [1/2]: ')).trim();
|
{ value: 'skip', label: 'Skip — use the CLI only' },
|
||||||
return answer === '1' ? 'telegram' : 'skip';
|
],
|
||||||
} finally {
|
}),
|
||||||
rl.close();
|
);
|
||||||
|
return choice as 'telegram' | 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
function printIntro(): void {
|
||||||
|
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||||
|
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||||
|
|
||||||
|
if (isReexec) {
|
||||||
|
p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function runBashScript(relPath: string): Promise<number> {
|
console.log();
|
||||||
return new Promise((resolve) => {
|
console.log(` ${wordmark}`);
|
||||||
const child = spawn('bash', [relPath], { stdio: 'inherit' });
|
console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`);
|
||||||
child.on('close', (code) => resolve(code ?? 1));
|
p.intro(`${brandChip(' setup:auto ')}`);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function runTsxScript(relPath: string, args: string[] = []): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn('pnpm', ['exec', 'tsx', relPath, ...args], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
child.on('close', (code) => resolve(code ?? 1));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(msg: string, hint?: string): never {
|
|
||||||
console.error(`\n[setup:auto] ${msg}`);
|
|
||||||
if (hint) console.error(` ${hint}`);
|
|
||||||
console.error(' Logs: logs/setup.log');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
printIntro();
|
||||||
|
|
||||||
const skip = new Set(
|
const skip = new Set(
|
||||||
(process.env.NANOCLAW_SKIP ?? '')
|
(process.env.NANOCLAW_SKIP ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -190,92 +423,113 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!skip.has('environment')) {
|
if (!skip.has('environment')) {
|
||||||
const env = await runStep('environment');
|
const res = await runQuietStep(
|
||||||
if (!env.ok) fail('environment check failed');
|
'environment',
|
||||||
|
{ running: 'Checking environment…', done: 'Environment OK.' },
|
||||||
|
);
|
||||||
|
if (!res.ok) fail('Environment check failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('container')) {
|
if (!skip.has('container')) {
|
||||||
const res = await runStep('container');
|
const res = await runQuietStep('container', {
|
||||||
|
running: 'Building the agent container image…',
|
||||||
|
done: 'Container image ready.',
|
||||||
|
failed: 'Container build failed.',
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.fields.ERROR === 'runtime_not_available') {
|
const err = res.terminal?.fields.ERROR;
|
||||||
|
if (err === 'runtime_not_available') {
|
||||||
fail(
|
fail(
|
||||||
'Docker is not available and could not be started automatically.',
|
'Docker is not available and could not be started automatically.',
|
||||||
'Install Docker Desktop or start it manually, then retry.',
|
'Install Docker Desktop or start it manually, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (res.fields.ERROR === 'docker_group_not_active') {
|
if (err === 'docker_group_not_active') {
|
||||||
fail(
|
fail(
|
||||||
'Docker was just installed but your shell is not yet in the `docker` group.',
|
'Docker was just installed but your shell is not yet in the `docker` group.',
|
||||||
'Log out and back in (or run `newgrp docker` in a new shell), then retry `pnpm run setup:auto`.',
|
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fail(
|
fail(
|
||||||
'container build/test failed',
|
'Container build/test failed.',
|
||||||
'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.',
|
'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
maybeReexecUnderSg();
|
maybeReexecUnderSg();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('onecli')) {
|
if (!skip.has('onecli')) {
|
||||||
const res = await runStep('onecli');
|
const res = await runQuietStep('onecli', {
|
||||||
|
running: 'Installing OneCLI credential vault…',
|
||||||
|
done: 'OneCLI installed.',
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.fields.ERROR === 'onecli_not_on_path_after_install') {
|
const err = res.terminal?.fields.ERROR;
|
||||||
|
if (err === 'onecli_not_on_path_after_install') {
|
||||||
fail(
|
fail(
|
||||||
'OneCLI installed but not on PATH.',
|
'OneCLI installed but not on PATH.',
|
||||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fail(
|
fail(
|
||||||
`OneCLI install failed (${res.fields.ERROR ?? 'unknown'})`,
|
`OneCLI install failed (${err ?? 'unknown'}).`,
|
||||||
'Check that curl + a writable ~/.local/bin are available; re-run `pnpm run setup:auto`.',
|
'Check that curl + a writable ~/.local/bin are available, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('auth')) {
|
if (!skip.has('auth')) {
|
||||||
if (anthropicSecretExists()) {
|
if (anthropicSecretExists()) {
|
||||||
console.log(
|
p.log.success('OneCLI already has an Anthropic secret — skipping.');
|
||||||
'\n── auth ────────────────────────────────────\n' +
|
|
||||||
'[setup:auto] OneCLI already has an Anthropic secret — skipping.',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('\n── auth ────────────────────────────────────');
|
p.log.step('Registering your Anthropic credential…');
|
||||||
const code = await runBashScript('setup/register-claude-token.sh');
|
console.log(
|
||||||
|
k.dim(' (browser sign-in or paste a token/key — this part is interactive)'),
|
||||||
|
);
|
||||||
|
console.log();
|
||||||
|
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
|
||||||
|
console.log();
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
fail(
|
fail(
|
||||||
'Anthropic credential registration failed or was aborted.',
|
'Anthropic credential registration failed or was aborted.',
|
||||||
'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.',
|
'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
p.log.success('Anthropic credential registered with OneCLI.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('mounts')) {
|
if (!skip.has('mounts')) {
|
||||||
const res = await runStep('mounts', ['--empty']);
|
const res = await runQuietStep('mounts', {
|
||||||
if (!res.ok && res.fields.STATUS !== 'skipped') {
|
running: 'Writing mount allowlist…',
|
||||||
fail('mount allowlist step failed');
|
done: 'Mount allowlist in place.',
|
||||||
}
|
skipped: 'Mount allowlist already configured.',
|
||||||
|
}, ['--empty']);
|
||||||
|
if (!res.ok) fail('Mount allowlist step failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('service')) {
|
if (!skip.has('service')) {
|
||||||
const res = await runStep('service');
|
const res = await runQuietStep('service', {
|
||||||
|
running: 'Installing the background service…',
|
||||||
|
done: 'Service installed and running.',
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
fail(
|
fail(
|
||||||
'service install failed',
|
'Service install failed.',
|
||||||
'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.',
|
'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (res.fields.DOCKER_GROUP_STALE === 'true') {
|
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||||
console.warn(
|
p.log.warn('Docker group stale in systemd session.');
|
||||||
'\n[setup:auto] Docker group stale in systemd session. Run:\n' +
|
p.log.message(
|
||||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
k.dim(
|
||||||
' systemctl --user restart nanoclaw',
|
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||||
|
' systemctl --user restart nanoclaw',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved once, reused by cli-agent + channel wiring.
|
|
||||||
let displayName: string | undefined;
|
let displayName: string | undefined;
|
||||||
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
||||||
if (needsDisplayName) {
|
if (needsDisplayName) {
|
||||||
@@ -285,15 +539,17 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('cli-agent')) {
|
if (!skip.has('cli-agent')) {
|
||||||
const res = await runStep('cli-agent', [
|
const res = await runQuietStep(
|
||||||
'--display-name',
|
'cli-agent',
|
||||||
displayName!,
|
{
|
||||||
'--agent-name',
|
running: 'Wiring the terminal agent…',
|
||||||
CLI_AGENT_NAME,
|
done: 'Terminal agent wired (try `pnpm run chat hi`).',
|
||||||
]);
|
},
|
||||||
|
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
fail(
|
fail(
|
||||||
'CLI agent wiring failed',
|
'CLI agent wiring failed.',
|
||||||
`Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`,
|
`Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,15 +558,19 @@ async function main(): Promise<void> {
|
|||||||
if (!skip.has('channel')) {
|
if (!skip.has('channel')) {
|
||||||
const choice = await askChannelChoice();
|
const choice = await askChannelChoice();
|
||||||
if (choice === 'telegram') {
|
if (choice === 'telegram') {
|
||||||
const installCode = await runBashScript('setup/add-telegram.sh');
|
p.log.step('Installing the Telegram adapter and collecting your bot token…');
|
||||||
|
console.log();
|
||||||
|
const installCode = await runInheritScript('bash', ['setup/add-telegram.sh']);
|
||||||
|
console.log();
|
||||||
if (installCode !== 0) {
|
if (installCode !== 0) {
|
||||||
fail(
|
fail(
|
||||||
'Telegram install failed.',
|
'Telegram install failed.',
|
||||||
'Re-run `bash setup/add-telegram.sh`, then `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.',
|
'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
p.log.success('Telegram adapter installed.');
|
||||||
|
|
||||||
const pair = await runStep('pair-telegram', ['--intent', 'main']);
|
const pair = await runPairTelegram();
|
||||||
if (!pair.ok) {
|
if (!pair.ok) {
|
||||||
fail(
|
fail(
|
||||||
'Telegram pairing failed.',
|
'Telegram pairing failed.',
|
||||||
@@ -318,8 +578,8 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformId = pair.fields.PLATFORM_ID;
|
const platformId = pair.terminal?.fields.PLATFORM_ID;
|
||||||
const pairedUserId = pair.fields.PAIRED_USER_ID;
|
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
|
||||||
if (!platformId || !pairedUserId) {
|
if (!platformId || !pairedUserId) {
|
||||||
fail(
|
fail(
|
||||||
'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.',
|
'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.',
|
||||||
@@ -331,54 +591,72 @@ async function main(): Promise<void> {
|
|||||||
process.env.NANOCLAW_AGENT_NAME?.trim() ||
|
process.env.NANOCLAW_AGENT_NAME?.trim() ||
|
||||||
(await askAgentName(DEFAULT_AGENT_NAME));
|
(await askAgentName(DEFAULT_AGENT_NAME));
|
||||||
|
|
||||||
console.log('\n── wiring first agent ──────────────────────────');
|
const init = await runQuietChild(
|
||||||
const initCode = await runTsxScript('scripts/init-first-agent.ts', [
|
'pnpm',
|
||||||
'--channel', 'telegram',
|
[
|
||||||
'--user-id', pairedUserId,
|
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||||
'--platform-id', platformId,
|
'--channel', 'telegram',
|
||||||
'--display-name', displayName!,
|
'--user-id', pairedUserId,
|
||||||
'--agent-name', agentName,
|
'--platform-id', platformId,
|
||||||
]);
|
'--display-name', displayName!,
|
||||||
if (initCode !== 0) {
|
'--agent-name', agentName,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
running: `Wiring ${agentName} to your Telegram chat…`,
|
||||||
|
done: `${agentName} is wired — welcome DM incoming.`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!init.ok) {
|
||||||
fail(
|
fail(
|
||||||
'Wiring the Telegram agent failed.',
|
'Wiring the Telegram agent failed.',
|
||||||
`Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`,
|
`Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
console.log(
|
p.log.info('No messaging channel wired — you can add one later with `/add-<channel>`.');
|
||||||
`\n[setup:auto] Telegram is wired. ${agentName} will DM you a welcome shortly.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skip.has('verify')) {
|
if (!skip.has('verify')) {
|
||||||
const res = await runStep('verify');
|
const res = await runQuietStep('verify', {
|
||||||
|
running: 'Verifying the install…',
|
||||||
|
done: 'Install verified.',
|
||||||
|
failed: 'Verification found issues.',
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):');
|
const notes: string[] = [];
|
||||||
if (res.fields.CREDENTIALS !== 'configured') {
|
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||||
console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`');
|
notes.push('• Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`.');
|
||||||
}
|
}
|
||||||
if (res.fields.AGENT_PING && res.fields.AGENT_PING !== 'ok' && res.fields.AGENT_PING !== 'skipped') {
|
const agentPing = res.terminal?.fields.AGENT_PING;
|
||||||
console.log(
|
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||||||
` • CLI agent did not reply (status: ${res.fields.AGENT_PING}). ` +
|
notes.push(
|
||||||
|
`• CLI agent did not reply (status: ${agentPing}). ` +
|
||||||
'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.',
|
'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!res.fields.CONFIGURED_CHANNELS) {
|
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||||
console.log(
|
notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …');
|
||||||
' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …',
|
|
||||||
);
|
|
||||||
console.log(' (CLI channel is already wired: `pnpm run chat hi`)');
|
|
||||||
}
|
}
|
||||||
|
if (notes.length > 0) {
|
||||||
|
p.note(notes.join('\n'), 'What’s left');
|
||||||
|
}
|
||||||
|
p.outro(k.yellow('Scripted steps done — some pieces still need you.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n[setup:auto] Complete.');
|
const nextSteps = [
|
||||||
|
`${k.cyan('Chat from the CLI:')} pnpm run chat hi`,
|
||||||
|
`${k.cyan('Tail host logs:')} tail -f logs/nanoclaw.log`,
|
||||||
|
`${k.cyan('Open Claude Code:')} claude`,
|
||||||
|
].join('\n');
|
||||||
|
p.note(nextSteps, 'Next steps');
|
||||||
|
p.outro(k.green('Setup complete.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(err);
|
p.log.error(err instanceof Error ? err.message : String(err));
|
||||||
|
p.cancel('Setup aborted.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
||||||
* operator to send the code from the chat they want to register.
|
* operator to send the code from the chat they want to register.
|
||||||
*
|
*
|
||||||
* Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human-
|
* Emits machine-readable status blocks only. The parent driver
|
||||||
* facing output is a focused banner for the code (no parseable block), plus a
|
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
|
||||||
* short line for wrong attempts / regenerations. A single machine-readable
|
* this step directly will look sparse — that's intentional.
|
||||||
* PAIR_TELEGRAM status block is still emitted at the end so the parent driver
|
*
|
||||||
* can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP.
|
* 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
|
* Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh
|
||||||
* copies in from the `channels` branch before this step runs. setup/ is
|
* copies in from the `channels` branch before this step runs. setup/ is
|
||||||
@@ -50,22 +55,6 @@ function intentToString(intent: PairingIntent): string {
|
|||||||
return `${intent.kind}:${intent.folder}`;
|
return `${intent.kind}:${intent.folder}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printCodeBanner(code: string): void {
|
|
||||||
const digits = code.split('').join(' ');
|
|
||||||
const content = [
|
|
||||||
'',
|
|
||||||
` PAIRING CODE: ${digits}`,
|
|
||||||
'',
|
|
||||||
' Send these digits from Telegram to your bot.',
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
const width = Math.max(...content.map((l) => l.length));
|
|
||||||
const top = ' ╔' + '═'.repeat(width + 2) + '╗';
|
|
||||||
const bot = ' ╚' + '═'.repeat(width + 2) + '╝';
|
|
||||||
const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║');
|
|
||||||
console.log(['', top, ...mid, bot, ''].join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function run(args: string[]): Promise<void> {
|
export async function run(args: string[]): Promise<void> {
|
||||||
const intent = parseArgs(args);
|
const intent = parseArgs(args);
|
||||||
|
|
||||||
@@ -78,19 +67,21 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
const MAX_REGENERATIONS = 5;
|
const MAX_REGENERATIONS = 5;
|
||||||
let record = await createPairing(intent);
|
let record = await createPairing(intent);
|
||||||
printCodeBanner(record.code);
|
emitStatus('PAIR_TELEGRAM_CODE', {
|
||||||
|
CODE: record.code,
|
||||||
|
REASON: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
||||||
try {
|
try {
|
||||||
const consumed = await waitForPairing(record.code, {
|
const consumed = await waitForPairing(record.code, {
|
||||||
onAttempt: (a) => {
|
onAttempt: (a) => {
|
||||||
console.log(
|
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
|
||||||
` Got "${a.candidate}" — doesn't match. A new code is on its way.`,
|
CANDIDATE: a.candidate,
|
||||||
);
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\n ✓ Telegram paired.\n');
|
|
||||||
emitStatus('PAIR_TELEGRAM', {
|
emitStatus('PAIR_TELEGRAM', {
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
@@ -107,12 +98,13 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
const invalidated = /invalidated by wrong code/.test(message);
|
const invalidated = /invalidated by wrong code/.test(message);
|
||||||
if (invalidated && regen < MAX_REGENERATIONS) {
|
if (invalidated && regen < MAX_REGENERATIONS) {
|
||||||
record = await createPairing(intent);
|
record = await createPairing(intent);
|
||||||
console.log('\n Previous code invalidated. New code:');
|
emitStatus('PAIR_TELEGRAM_CODE', {
|
||||||
printCodeBanner(record.code);
|
CODE: record.code,
|
||||||
|
REASON: 'regenerated',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const reason = invalidated ? 'max-regenerations-exceeded' : message;
|
const reason = invalidated ? 'max-regenerations-exceeded' : message;
|
||||||
console.error(`\n ✗ Pairing failed: ${reason}`);
|
|
||||||
emitStatus('PAIR_TELEGRAM', {
|
emitStatus('PAIR_TELEGRAM', {
|
||||||
STATUS: 'failed',
|
STATUS: 'failed',
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ esac
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}"
|
echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}"
|
||||||
echo "Registering with OneCLI as '$SECRET_NAME' (host pattern: $HOST_PATTERN)…"
|
echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…"
|
||||||
|
|
||||||
onecli secrets create \
|
onecli secrets create \
|
||||||
--name "$SECRET_NAME" \
|
--name "$SECRET_NAME" \
|
||||||
|
|||||||
Reference in New Issue
Block a user