From 3fa001409edc7b4aac1a7abf6fd6021475c58185 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 23:19:30 +0300 Subject: [PATCH] feat(setup): wire Signal into the auto setup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bash nanoclaw.sh` can now offer Signal as a channel choice, scan the signal-cli link QR in the terminal, and wire up the first agent end to end — mirroring the WhatsApp and Telegram flows. Pieces: - setup/add-signal.sh — non-interactive installer. Fetches src/channels/signal.ts + signal.test.ts from the channels branch, appends the self-registration import, installs qrcode (for the setup-flow QR render), and builds. Idempotent and standalone-runnable. - setup/signal-auth.ts — step runner. Spawns `signal-cli link --name NanoClaw`, watches stdout for the `sgnl://linkdevice?…` (or legacy `tsdevice://`) URL, emits SIGNAL_AUTH_QR with it. On exit 0, runs `signal-cli -o json listAccounts` and reports the new account via SIGNAL_AUTH STATUS=success. Pre-check via listAccounts returns STATUS=skipped if an account is already linked. - setup/channels/signal.ts — interactive driver. Probes for signal-cli (offering `brew install signal-cli` on macOS or linking GitHub releases on Linux if missing), runs add-signal.sh, renders each SIGNAL_AUTH_QR block as a terminal QR inside a clack spinner, persists SIGNAL_ACCOUNT to .env + data/env/env, restarts the service, then wires the first agent via init-first-agent. - setup/index.ts: register `signal-auth` in the STEPS map. - setup/auto.ts: add 'signal' to ChannelChoice, import the driver, add it to the channel picker (after WhatsApp, hint "needs signal-cli installed"), branch the dispatch, and map channelDmLabel. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-signal.sh | 95 +++++++++++ setup/auto.ts | 11 ++ setup/channels/signal.ts | 357 +++++++++++++++++++++++++++++++++++++++ setup/index.ts | 1 + setup/signal-auth.ts | 182 ++++++++++++++++++++ 5 files changed, 646 insertions(+) create mode 100755 setup/add-signal.sh create mode 100644 setup/channels/signal.ts create mode 100644 setup/signal-auth.ts diff --git a/setup/add-signal.sh b/setup/add-signal.sh new file mode 100755 index 0000000..8ebf2b9 --- /dev/null +++ b/setup/add-signal.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Install the Signal adapter in an already-running NanoClaw checkout. +# Non-interactive — the operator-facing "install signal-cli" + QR scan +# live in setup/channels/signal.ts. This script only: +# +# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels +# branch. +# 2. Appends the self-registration import to src/channels/index.ts. +# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has +# no npm deps). +# 4. Builds. +# +# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli +# link has produced a number; that keeps this script idempotent and +# re-runnable without re-auth. +# +# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All +# chatty progress goes 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-signal/SKILL.md. +QRCODE_VERSION="qrcode@1.5.4" +QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" + +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_SIGNAL ===" + echo "STATUS: ${status}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-signal] $*" >&2; } + +need_install() { + [ ! -f src/channels/signal.ts ] && return 0 + ! grep -q "^import './signal.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 "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter files from ${CHANNELS_BRANCH}…" + for f in \ + src/channels/signal.ts \ + src/channels/signal.test.ts + do + git show "${CHANNELS_BRANCH}:$f" > "$f" || { + emit_status failed "git show ${CHANNELS_BRANCH}:$f failed" + exit 1 + } + done + + if ! grep -q "^import './signal.js';" src/channels/index.ts; then + echo "import './signal.js';" >> src/channels/index.ts + fi +fi + +# qrcode is needed by setup/signal-auth.ts to render the linking URL as a +# terminal QR. Install idempotently — if it's already present (e.g. from a +# prior WhatsApp install) pnpm is a no-op. +if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then + log "Installing ${QRCODE_VERSION}…" + pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${QRCODE_VERSION} failed" + exit 1 + } +fi + +log "Building…" +pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 +} + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 4c20262..cff2f63 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -28,6 +28,7 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; +import { runSignalChannel } from './channels/signal.js'; import { runSlackChannel } from './channels/slack.js'; import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; @@ -54,6 +55,7 @@ type ChannelChoice = | 'telegram' | 'discord' | 'whatsapp' + | 'signal' | 'teams' | 'slack' | 'imessage' @@ -315,6 +317,8 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else if (channelChoice === 'whatsapp') { await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { @@ -442,6 +446,8 @@ function channelDmLabel(choice: ChannelChoice): string | null { return 'Discord DMs'; case 'whatsapp': return 'WhatsApp'; + case 'signal': + return 'Signal'; case 'teams': return 'Teams'; case 'imessage': @@ -835,6 +841,11 @@ async function askChannelChoice(): Promise { { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { + value: 'signal', + label: 'Yes, connect Signal', + hint: 'needs signal-cli installed', + }, { value: 'imessage', label: 'Yes, connect iMessage (experimental)', diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts new file mode 100644 index 0000000..9e54cb9 --- /dev/null +++ b/setup/channels/signal.ts @@ -0,0 +1,357 @@ +/** + * Signal channel flow for setup:auto. + * + * `runSignalChannel(displayName)` owns the full branch from signal-cli + * presence check through the welcome DM: + * + * 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it, + * offer `brew install signal-cli` inline. On Linux, surface the + * GitHub releases URL and bail with an actionable error. + * 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent). + * 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as + * a terminal QR the operator scans from Signal → Linked Devices. + * 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env). + * 5. Kick the service so the adapter picks up the new credentials. + * 6. Ask operator role + agent name. + * 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome + * DM path delivers the greeting through the adapter. + * + * Signal's `link` flow creates a *secondary* device. The phone number + * comes from the primary (the phone that scanned the QR); this host then + * sends/receives as that primary number. No registration of new numbers. + * + * 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 { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runSignalChannel(displayName: string): Promise { + await ensureSignalCli(); + + const install = await runQuietChild( + 'signal-install', + 'bash', + ['setup/add-signal.sh'], + { + running: 'Installing the Signal adapter…', + done: 'Signal adapter installed.', + skipped: 'Signal adapter already installed.', + }, + ); + if (!install.ok) { + await fail( + 'signal-install', + "Couldn't install the Signal adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const auth = await runSignalAuth(); + if (!auth.ok) { + const reason = auth.terminal?.fields.ERROR ?? 'unknown'; + await fail( + 'signal-auth', + `Signal link failed (${reason}).`, + reason === 'qr_timeout' + ? 'The code expired. Re-run setup to get a fresh one.' + : 'Re-run setup to try again.', + ); + } + + const account = auth.terminal?.fields.ACCOUNT; + if (!account) { + await fail( + 'signal-auth', + 'Linked with Signal but couldn\'t read the phone number back.', + 'Run `signal-cli listAccounts` to confirm, then re-run setup.', + ); + } + + writeSignalAccount(account!); + await restartService(); + + const role = await askOperatorRole('Signal'); + setupLog.userInput('signal_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'signal', + '--user-id', account!, + '--platform-id', account!, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Connecting ${agentName} to Signal…`, + done: `${agentName} is ready. Check Signal for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'signal', + AGENT_NAME: agentName, + PLATFORM_ID: account!, + ROLE: role, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +async function ensureSignalCli(): Promise { + const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (!probe.error && probe.status === 0) return; + + if (process.platform === 'darwin') { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'The quickest way on macOS is Homebrew:', + '', + k.cyan(' brew install signal-cli'), + '', + "Install it in another terminal, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } else { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'Grab the latest release from GitHub:', + '', + k.cyan(' https://github.com/AsamK/signal-cli/releases'), + '', + "Install it, make sure `signal-cli --version` works, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } + await fail( + 'signal-install', + 'signal-cli is required but not installed.', + 'Install it and re-run setup.', + ); +} + +async function runSignalAuth(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('signal-auth'); + const start = Date.now(); + const s = p.spinner(); + s.start('Starting Signal link…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number): void => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + // Tracks how many lines the QR block occupies so we can wipe it in-place + // once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's, + // but we still want to erase the QR from screen once it's served). + let qrLinesPrinted = 0; + + const result = await spawnStep( + 'signal-auth', + [], + (block: Block) => { + if (block.type === 'SIGNAL_AUTH_QR') { + const qr = block.fields.QR ?? ''; + if (!qr) return; + void renderQr(qr).then((lines) => { + stopSpinner('Scan this QR from Signal → Settings → Linked Devices.'); + process.stdout.write(lines.join('\n') + '\n'); + qrLinesPrinted = lines.length; + s.start('Waiting for you to scan…'); + spinnerActive = true; + }); + } else if (block.type === 'SIGNAL_AUTH') { + const status = block.fields.STATUS; + // Wipe the QR block regardless of outcome — it's either scanned + // and useless, or expired and misleading. + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + const account = block.fields.ACCOUNT; + if (status === 'skipped') { + stopSpinner( + account + ? `Signal already linked as ${k.cyan(account)}.` + : 'Signal already linked.', + ); + } else if (status === 'success') { + stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`); + } else if (status === 'failed') { + const err = block.fields.ERROR ?? 'unknown'; + stopSpinner(`Signal link failed: ${err}`, 1); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Signal link ended unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('signal-auth', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** + * Render the raw linking URL as a block-art QR, returned line-by-line so + * the caller can count lines for in-place cleanup. Uses small-mode so the + * code stays scannable on 24-row terminals. If qrcode isn't installed + * (add-signal.sh should have handled it, but we're defensive), fall back + * to the raw URL and ask the user to paste it into an external renderer. + */ +async function renderQr(url: string): Promise { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(url, { type: 'terminal', small: true }); + const caption = k.dim( + ' Signal → Settings → Linked Devices → Link New Device → scan.', + ); + return [...qrText.trimEnd().split('\n'), '', caption]; + } catch { + return [ + 'Linking URL (render at https://qr.io or similar):', + '', + url, + '', + k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'), + ]; + } +} + +/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */ +function writeSignalAccount(account: string): void { + const envPath = path.join(process.cwd(), '.env'); + let contents = ''; + try { + contents = fs.readFileSync(envPath, 'utf-8'); + } catch { + contents = ''; + } + if (/^SIGNAL_ACCOUNT=/m.test(contents)) { + contents = contents.replace( + /^SIGNAL_ACCOUNT=.*$/m, + `SIGNAL_ACCOUNT=${account}`, + ); + } else { + if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n'; + contents += `SIGNAL_ACCOUNT=${account}\n`; + } + fs.writeFileSync(envPath, contents); + + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(envPath, path.join(containerEnvDir, 'env')); + + setupLog.userInput('signal_account', account); +} + +async function restartService(): Promise { + const s = p.spinner(); + s.start('Restarting NanoClaw so it sees your Signal account…'); + const start = Date.now(); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync( + 'launchctl', + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`], + { stdio: 'ignore' }, + ); + } else if (platform === 'linux') { + const unit = getSystemdUnit(); + const user = spawnSync('systemctl', ['--user', 'restart', unit], { + stdio: 'ignore', + }); + if (user.status !== 0) { + spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' }); + } + } + // Give the adapter a moment to connect to signal-cli before + // init-first-agent's welcome DM hits the delivery path. + await new Promise((r) => setTimeout(r, 5000)); + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step('signal-restart', 'success', Date.now() - start, { + PLATFORM: platform, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + s.stop(`Restart may have failed: ${message}`, 1); + setupLog.step('signal-restart', 'failed', Date.now() - start, { + ERROR: message, + }); + // Non-fatal — the user can restart manually if init-first-agent fails. + } +} + +async function resolveAgentName(): Promise { + 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; +} diff --git a/setup/index.ts b/setup/index.ts index 25d1934..200b9e2 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -16,6 +16,7 @@ const STEPS: Record< register: () => import('./register.js'), groups: () => import('./groups.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), + 'signal-auth': () => import('./signal-auth.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/signal-auth.ts b/setup/signal-auth.ts new file mode 100644 index 0000000..ce289db --- /dev/null +++ b/setup/signal-auth.ts @@ -0,0 +1,182 @@ +/** + * Step: signal-auth — link this host to an existing Signal account via + * signal-cli's QR-code flow. + * + * signal-cli `link` opens a bi-directional handshake with the Signal + * servers: it prints one line containing a linking URL (`sgnl://linkdevice?…` + * or older `tsdevice://linkdevice?…`), then blocks until either the user + * scans it from an existing Signal install, or the code expires. On + * success, a secondary account is created under the user's signal-cli + * data directory, associated with the phone number of the scanner. + * + * Methods: + * (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR + * with the URL, wait for completion. + * + * Block schema (parent parses these): + * SIGNAL_AUTH_QR { QR: "" } — one-shot + * SIGNAL_AUTH { STATUS: success, ACCOUNT: + } — terminal + * { STATUS: skipped, ACCOUNT, REASON: already-authenticated } + * { STATUS: failed, ERROR: } + * + * STATUS values match the runner's vocabulary (success/skipped/failed) so + * spawnStep recognises them and sets `ok` correctly; Signal-specific UI + * lives in setup/channels/signal.ts. + * + * If one or more accounts are already linked (discovered via + * `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH + * STATUS=skipped with the first account so the driver can reuse it. + * Selecting a different existing account is a driver concern. + */ +import { spawn, spawnSync } from 'child_process'; + +import { emitStatus } from './status.js'; + +const LINK_TIMEOUT_MS = 180_000; +const DEFAULT_DEVICE_NAME = 'NanoClaw'; + +interface SignalAccount { + account?: string; + registered?: boolean; +} + +function cliPath(): string { + return process.env.SIGNAL_CLI_PATH || 'signal-cli'; +} + +/** + * Query signal-cli for currently linked accounts. Empty array if none + * configured, no binary, or the call fails for any other reason. + */ +function listAccounts(): string[] { + const cli = cliPath(); + try { + const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return []; + const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[]; + return parsed + .filter((a) => a.registered !== false) + .map((a) => a.account ?? '') + .filter(Boolean); + } catch { + return []; + } +} + +export async function run(_args: string[]): Promise { + const cli = cliPath(); + + // Verify signal-cli exists before we commit to the long-running link. + // The driver checks too, but this keeps the step honest when run alone. + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (probe.error || probe.status !== 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'failed', + ERROR: 'signal-cli not found. Install signal-cli first.', + }); + return; + } + + const existing = listAccounts(); + if (existing.length > 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'skipped', + ACCOUNT: existing[0], + REASON: 'already-authenticated', + }); + return; + } + + await new Promise((resolve) => { + let settled = false; + let qrEmitted = false; + + const finish = (block: Record, code: number): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + emitStatus('SIGNAL_AUTH', block); + resolve(); + setTimeout(() => process.exit(code), 500); + }; + + const timer = setTimeout(() => { + try { + child.kill('SIGTERM'); + } catch { + /* ignore */ + } + finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1); + }, LINK_TIMEOUT_MS); + + const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // stdout carries the URL on the first line; subsequent lines may print + // status like "Associated with: +1555…". We don't strictly need to parse + // the number — listAccounts after exit is the source of truth — but the + // URL match drives the QR emit, which is the whole point. + let stdoutBuf = ''; + const handleStdout = (chunk: Buffer): void => { + stdoutBuf += chunk.toString('utf-8'); + let idx: number; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + // Match both modern (sgnl://) and legacy (tsdevice://) schemes. + if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) { + qrEmitted = true; + emitStatus('SIGNAL_AUTH_QR', { QR: line }); + } + } + }; + child.stdout.on('data', handleStdout); + + // Capture stderr for the transcript / log — signal-cli writes warnings + // and errors there. We don't emit on partial stderr lines since a + // successful link can still produce noise. + let stderrBuf = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf-8'); + }); + + child.on('error', (err) => { + finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1); + }); + + child.on('close', (code) => { + // After a successful link, signal-cli exits 0 and the newly linked + // account shows up in listAccounts. Use that as the source of truth + // rather than scraping stdout — more robust across signal-cli versions. + if (code === 0) { + const post = listAccounts(); + if (post.length === 0) { + finish( + { STATUS: 'failed', ERROR: 'link exited 0 but no account registered' }, + 1, + ); + return; + } + finish({ STATUS: 'success', ACCOUNT: post[0] }, 0); + return; + } + + // Non-zero exit. Surface the last non-empty stderr line for context; + // signal-cli's own error messages are usually informative. + const lastErr = + stderrBuf + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .slice(-1)[0] ?? `signal-cli link exited with code ${code}`; + finish({ STATUS: 'failed', ERROR: lastErr }, 1); + }); + }); +}