mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
feat(setup): wire Signal into the auto setup flow
`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) <noreply@anthropic.com>
This commit is contained in:
Executable
+95
@@ -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
|
||||
@@ -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<void> {
|
||||
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<ChannelChoice> {
|
||||
{ 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)',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<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;
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: "<sgnl:// or tsdevice:// url>" } — one-shot
|
||||
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } — terminal
|
||||
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
|
||||
* { STATUS: failed, ERROR: <reason> }
|
||||
*
|
||||
* 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<void> {
|
||||
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<void>((resolve) => {
|
||||
let settled = false;
|
||||
let qrEmitted = false;
|
||||
|
||||
const finish = (block: Record<string, string | number | boolean>, 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user