feat(signal): link first account from setup QR

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-18 12:51:57 +00:00
committed by roboclaw-bot
parent bfd832ad3a
commit bfdde201ee
7 changed files with 561 additions and 58 deletions
+9 -5
View File
@@ -37,13 +37,16 @@ Bare plugin specs try ClawHub first, then npm fallback. Force a source with `ope
```bash
openclaw channels add
```
The wizard detects whether `signal-cli` is on `PATH` and, when missing, offers to install it: downloads the official native GraalVM build on Linux x86-64, or installs via Homebrew on macOS and other architectures. It then prompts for the bot number and `signal-cli` path.
The wizard detects whether `signal-cli` is on `PATH` and, when missing, offers to install it: downloads the official native GraalVM build on Linux x86-64, or installs via Homebrew on macOS and other architectures.
A hosted setup client with QR support can link the first managed-native Signal account directly. Scan the displayed QR in Signal; setup records the linked number without another number prompt. CLI setup, additional accounts, relinking, external daemons, and containers keep the manual flow below.
For non-interactive setup, `openclaw channels add --channel signal` also accepts `--signal-number <e164>` for the bot phone number, plus `--http-host <host>` and `--http-port <port>` for the Signal daemon endpoint (default `127.0.0.1:8080`).
</Step>
<Step title="Link or register the account">
- **QR link (fastest):** `signal-cli link -n "OpenClaw"`, then scan with Signal. See [Path A](#setup-path-a-link-existing-signal-account-qr).
- **Hosted QR link (fastest):** if setup displayed a QR and confirmed the account, continue to verification.
- **Manual QR link:** otherwise run `signal-cli link -n "OpenClaw"`, then scan with Signal. See [Path A](#setup-path-a-link-existing-signal-account-qr).
- **SMS registration:** dedicated number with captcha + SMS verification. See [Path B](#setup-path-b-register-dedicated-bot-number-sms-linux).
</Step>
@@ -91,9 +94,10 @@ Multi-account support: use `channels.signal.accounts` with per-account config an
## Setup path A: link existing Signal account (QR)
1. Install `signal-cli` (JVM or native build), or let `openclaw channels add` install it for you.
2. Link a bot account: `signal-cli link -n "OpenClaw"`, then scan the QR in Signal.
3. Configure Signal and start the gateway.
1. Install `signal-cli` (JVM or native build), or let setup install it for you.
2. In a hosted setup client that displays the first-account QR, scan it in Signal under **Settings > Linked devices**, then continue.
3. For CLI setup, additional accounts, relinking, external daemons, or containers, run `signal-cli link -n "OpenClaw"` and scan the terminal QR instead.
4. Configure Signal and start the gateway.
## Setup path B: register dedicated bot number (SMS, Linux)
+34
View File
@@ -1,5 +1,39 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { signalCheck } from "./client-adapter.js";
import { formatSignalDaemonExit, type SignalDaemonHandle } from "./daemon.js";
export async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
timeoutMs: number;
logAfterMs: number;
logIntervalMs?: number;
runtime: RuntimeEnv;
waitForTransportReadyFn?: typeof waitForTransportReady;
}): Promise<void> {
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
await waitForTransportReadyFn({
label: "signal daemon",
timeoutMs: params.timeoutMs,
logAfterMs: params.logAfterMs,
logIntervalMs: params.logIntervalMs,
pollIntervalMs: 150,
abortSignal: params.abortSignal,
runtime: params.runtime,
check: async () => {
const res = await signalCheck(params.baseUrl, 1000);
if (res.ok) {
return { ok: true };
}
return {
ok: false,
error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"),
};
},
});
}
export function createSignalDaemonLifecycle(params: { abortSignal?: AbortSignal }) {
let daemonHandle: SignalDaemonHandle | null = null;
let daemonStopRequested = false;
+3
View File
@@ -48,6 +48,7 @@ describe("spawnSignalDaemon", () => {
configPath: "~/.openclaw/signal-cli",
httpHost: "127.0.0.1",
httpPort: 8080,
receiveMode: "manual",
});
expect(spawnMock).toHaveBeenCalledWith(
@@ -59,6 +60,8 @@ describe("spawnSignalDaemon", () => {
"--http",
"127.0.0.1:8080",
"--no-receive-stdout",
"--receive-mode",
"manual",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
+2 -33
View File
@@ -46,9 +46,9 @@ import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runti
import { resolveSignalAccount, resolveSignalReplyToMode } from "./accounts.js";
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
import { addSignalApprovalReactionHintToStructuredPayload } from "./approval-reactions.js";
import { signalRpcRequest, signalCheck } from "./client-adapter.js";
import { signalRpcRequest } from "./client-adapter.js";
import type { SignalTransportKind } from "./client-adapter.js";
import { createSignalDaemonLifecycle } from "./daemon-lifecycle.js";
import { createSignalDaemonLifecycle, waitForSignalDaemonReady } from "./daemon-lifecycle.js";
import { spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
import { createSignalEventHandler } from "./monitor/event-handler.js";
@@ -194,37 +194,6 @@ function buildSignalReactionSystemEventText(params: {
return params.groupLabel ? `${withTarget} in ${params.groupLabel}` : withTarget;
}
async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
timeoutMs: number;
logAfterMs: number;
logIntervalMs?: number;
runtime: RuntimeEnv;
waitForTransportReadyFn?: typeof waitForTransportReady;
}): Promise<void> {
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
await waitForTransportReadyFn({
label: "signal daemon",
timeoutMs: params.timeoutMs,
logAfterMs: params.logAfterMs,
logIntervalMs: params.logIntervalMs,
pollIntervalMs: 150,
abortSignal: params.abortSignal,
runtime: params.runtime,
check: async () => {
const res = await signalCheck(params.baseUrl, 1000);
if (res.ok) {
return { ok: true };
}
return {
ok: false,
error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"),
};
},
});
}
const SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES = 64 * 1024;
const SIGNAL_BASE64_OVERHEAD_NUMERATOR = 4;
const SIGNAL_BASE64_OVERHEAD_DENOMINATOR = 3;
+6
View File
@@ -40,6 +40,7 @@ import { normalizeSignalTransportHost, normalizeSignalTransportUrl } from "./tra
const t = createSetupTranslator();
const channel = "signal" as const;
export const SIGNAL_LINK_COMPLETED_CREDENTIAL = "signalLinkCompleted";
const signalSetupFields = {
signalNumber: {
@@ -297,6 +298,9 @@ export const signalNumberTextInput: ChannelSetupWizardTextInput = {
validate: ({ value }) =>
normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR,
normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value,
shouldPrompt: ({ credentialValues }) =>
credentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] !== "true",
applyCurrentValue: true,
};
export const signalCompletionNote = {
@@ -307,6 +311,8 @@ export const signalCompletionNote = {
`Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`,
`Docs: ${formatDocsLink("/signal", "signal")}`,
],
shouldShow: ({ credentialValues }: { credentialValues: Record<string, string | undefined> }) =>
credentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] !== "true",
};
const signalSetupAdapterBase = createPatchedAccountSetupAdapter<SignalSetupInput>({
+286
View File
@@ -0,0 +1,286 @@
// Signal setup tests cover hosted first-account linking and manual fallbacks.
import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/setup";
import { WizardCancelledError } from "openclaw/plugin-sdk/setup";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SIGNAL_LINK_COMPLETED_CREDENTIAL } from "./setup-core.js";
import { signalSetupWizard } from "./setup-surface.js";
const mocks = vi.hoisted(() => ({
detectBinary: vi.fn(),
installSignalCli: vi.fn(),
rpc: vi.fn(),
spawnDaemon: vi.fn(),
waitReady: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/setup-tools")>()),
detectBinary: mocks.detectBinary,
}));
vi.mock("./client.js", () => ({ signalRpcRequest: mocks.rpc }));
vi.mock("./daemon.js", () => ({ spawnSignalDaemon: mocks.spawnDaemon }));
vi.mock("./daemon-lifecycle.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./daemon-lifecycle.js")>()),
waitForSignalDaemonReady: mocks.waitReady,
}));
vi.mock("./install-signal-cli.js", () => ({ installSignalCli: mocks.installSignalCli }));
type PrepareParams = Parameters<NonNullable<typeof signalSetupWizard.prepare>>[0];
function createPrompter(
params: {
confirm?: boolean;
qrCode?: (value: Parameters<NonNullable<WizardPrompter["qrCode"]>>[0]) => Promise<unknown>;
selectedAccount?: string;
} = {},
) {
const note = vi.fn(async () => {});
const select = vi.fn(async () => params.selectedAccount ?? "+15555550123");
const qrCode = params.qrCode
? vi.fn(params.qrCode)
: vi.fn(async (value: Parameters<NonNullable<WizardPrompter["qrCode"]>>[0]) => value.settled);
return {
note,
qrCode,
select,
prompter: {
confirm: vi.fn(async () => params.confirm ?? false),
note,
qrCode,
select,
} as unknown as WizardPrompter,
};
}
function createDaemonHandle() {
return {
exited: new Promise<never>(() => {}),
isExited: () => false,
stop: vi.fn(async () => {}),
};
}
async function prepareSignal(params: {
cfg?: OpenClawConfig;
prompter?: WizardPrompter;
signal?: AbortSignal;
includeSignal?: boolean;
beforePersistentEffect?: () => Promise<void>;
}) {
const prepare = signalSetupWizard.prepare;
if (!prepare) {
throw new Error("expected Signal setup prepare hook");
}
const options: NonNullable<PrepareParams["options"]> = {
allowSignalInstall: true,
...(params.includeSignal === false
? {}
: { signal: params.signal ?? new AbortController().signal }),
...(params.beforePersistentEffect
? { beforePersistentEffect: params.beforePersistentEffect }
: {}),
};
return await prepare({
cfg: params.cfg ?? {},
accountId: "default",
credentialValues: {},
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter: params.prompter ?? createPrompter().prompter,
options,
});
}
beforeEach(() => {
vi.clearAllMocks();
mocks.detectBinary.mockResolvedValue(true);
mocks.installSignalCli.mockResolvedValue({ ok: true, cliPath: "/tools/signal-cli" });
mocks.spawnDaemon.mockImplementation(() => createDaemonHandle());
mocks.waitReady.mockResolvedValue(undefined);
});
describe("Signal hosted setup linking", () => {
it("links the first managed-native account through one owned multi-account daemon", async () => {
const events: string[] = [];
const deviceLinkUri = "sgnl://linkdevice?uuid=test&pub_key=test";
mocks.rpc.mockImplementation(async (method: string) => {
events.push(method);
if (method === "listAccounts") {
return [];
}
if (method === "startLink") {
return { deviceLinkUri };
}
if (method === "finishLink") {
return { number: "+15555550123" };
}
throw new Error(`unexpected method ${method}`);
});
const prompt = createPrompter({
qrCode: async (value) => {
events.push("qrCode");
return await value.settled;
},
});
const result = await prepareSignal({
prompter: prompt.prompter,
});
expect(mocks.spawnDaemon).toHaveBeenCalledWith({
cliPath: "signal-cli",
httpHost: "127.0.0.1",
httpPort: 8080,
receiveMode: "manual",
});
expect(mocks.spawnDaemon.mock.calls[0]?.[0]).not.toHaveProperty("account");
expect(events).toEqual(["listAccounts", "startLink", "finishLink", "qrCode"]);
expect(prompt.qrCode).toHaveBeenCalledWith({
title: "Link Signal",
message: "In Signal, open Settings → Linked devices and scan this QR code.",
text: deviceLinkUri,
expiresInMs: 120_000,
settled: expect.any(Promise),
});
expect(result?.credentialValues).toEqual({
signalNumber: "+15555550123",
[SIGNAL_LINK_COMPLETED_CREDENTIAL]: "true",
});
expect(mocks.spawnDaemon.mock.results[0]?.value.stop).toHaveBeenCalledOnce();
const numberInput = signalSetupWizard.textInputs?.find(
(input) => input.inputKey === "signalNumber",
);
expect(numberInput?.applyCurrentValue).toBe(true);
expect(
await numberInput?.shouldPrompt?.({
cfg: {},
accountId: "default",
credentialValues: result?.credentialValues ?? {},
currentValue: "+15555550123",
}),
).toBe(false);
expect(
await signalSetupWizard.completionNote?.shouldShow?.({
cfg: {},
accountId: "default",
credentialValues: result?.credentialValues ?? {},
}),
).toBe(false);
});
it("reuses a selected local signal-cli account without starting another link", async () => {
mocks.rpc.mockResolvedValueOnce([{ number: "+15555550125" }, { number: "+15555550123" }]);
const prompt = createPrompter({ selectedAccount: "+15555550125" });
const result = await prepareSignal({ prompter: prompt.prompter });
expect(prompt.select).toHaveBeenCalledWith({
message: "Choose the Signal account for OpenClaw",
options: [
{ label: "+15555550123", value: "+15555550123" },
{ label: "+15555550125", value: "+15555550125" },
],
});
expect(mocks.rpc).toHaveBeenCalledTimes(1);
expect(prompt.qrCode).not.toHaveBeenCalled();
expect(result?.credentialValues?.signalNumber).toBe("+15555550125");
});
it.each([
{ label: "without QR support", includeSignal: true, includeQr: false, cfg: {} },
{ label: "without hosted cancellation", includeSignal: false, includeQr: true, cfg: {} },
{
label: "for an already configured account",
includeSignal: true,
includeQr: true,
cfg: { channels: { signal: { account: "+15555550123" } } } as OpenClawConfig,
},
{
label: "for an external daemon",
includeSignal: true,
includeQr: true,
cfg: {
channels: {
signal: {
transport: { kind: "external-native", url: "http://127.0.0.1:8080" },
},
},
} as OpenClawConfig,
},
])("keeps the manual setup flow $label", async ({ includeSignal, includeQr, cfg }) => {
const prompt = createPrompter();
const prompter = includeQr
? prompt.prompter
: ({ ...prompt.prompter, qrCode: undefined } as WizardPrompter);
const result = await prepareSignal({
cfg,
prompter,
includeSignal,
});
expect(mocks.spawnDaemon).not.toHaveBeenCalled();
expect(result?.credentialValues?.signalNumber).toBeUndefined();
expect(
await signalSetupWizard.completionNote?.shouldShow?.({
cfg,
accountId: "default",
credentialValues: result?.credentialValues ?? {},
}),
).toBe(true);
});
it.each([
{
label: "invalid start URI",
responses: [[], { deviceLinkUri: "https://example.invalid/private-token" }],
},
{
label: "invalid finish number",
responses: [
[],
{ deviceLinkUri: "sgnl://linkdevice?uuid=private-token&pub_key=test" },
{ number: "private-number" },
],
},
])("falls back without exposing dependency data for an $label", async ({ responses }) => {
for (const response of responses) {
mocks.rpc.mockResolvedValueOnce(response);
}
const prompt = createPrompter();
const result = await prepareSignal({ prompter: prompt.prompter });
const notes = prompt.note.mock.calls.flat().map(String).join("\n");
expect(result).toBeUndefined();
expect(notes).toContain("Automatic Signal linking could not complete");
expect(notes).not.toContain("private-token");
expect(notes).not.toContain("private-number");
expect(mocks.spawnDaemon.mock.results[0]?.value.stop).toHaveBeenCalledOnce();
});
it("aborts linking and reaps its daemon without showing a dependency failure", async () => {
const controller = new AbortController();
mocks.rpc
.mockResolvedValueOnce([])
.mockResolvedValueOnce({ deviceLinkUri: "sgnl://linkdevice?uuid=test&pub_key=test" })
.mockReturnValueOnce(new Promise<never>(() => {}));
const prompt = createPrompter({
qrCode: async () => {
controller.abort(new WizardCancelledError());
throw new WizardCancelledError();
},
});
await expect(
prepareSignal({
prompter: prompt.prompter,
signal: controller.signal,
beforePersistentEffect: vi.fn(async () => {}),
}),
).rejects.toBeInstanceOf(WizardCancelledError);
expect(prompt.note).not.toHaveBeenCalled();
expect(mocks.spawnDaemon.mock.results[0]?.value.stop).toHaveBeenCalledOnce();
});
});
+221 -20
View File
@@ -1,15 +1,25 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
// Signal plugin module implements setup surface behavior.
import {
createSetupTranslator,
createDetectedBinaryStatus,
setSetupChannelEnabled,
type ChannelSetupWizard,
type OpenClawConfig,
type WizardPrompter,
WizardCancelledError,
} from "openclaw/plugin-sdk/setup";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { detectBinary, formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
import { createSignalDaemonLifecycle, waitForSignalDaemonReady } from "./daemon-lifecycle.js";
import { spawnSignalDaemon } from "./daemon.js";
import { installSignalCli } from "./install-signal-cli.js";
import {
createSignalCliPathTextInput,
normalizeSignalAccountInput,
SIGNAL_LINK_COMPLETED_CREDENTIAL,
signalCompletionNote,
signalDmPolicy,
signalNumberTextInput,
@@ -18,6 +28,9 @@ import {
const t = createSetupTranslator();
const channel = "signal" as const;
const SIGNAL_LINK_URI_MAX_LENGTH = 4096;
const SIGNAL_LINK_RPC_MAX_BYTES = 16 * 1024;
const SIGNAL_LINK_EXPIRES_IN_MS = 120_000;
const configuredLabel = t("wizard.channels.statusConfigured");
const unconfiguredLabel = t("wizard.channels.statusNeedsSetup");
const managedStatus = createDetectedBinaryStatus({
@@ -43,6 +56,173 @@ const managedStatus = createDetectedBinaryStatus({
detectBinary,
});
function parseSignalAccounts(value: unknown): string[] {
if (!Array.isArray(value) || value.length > 100) {
throw new Error("invalid Signal account list");
}
return [
...new Set(
value.map((entry) => {
const rawNumber = isRecord(entry) ? entry.number : undefined;
const number =
typeof rawNumber === "string" ? normalizeSignalAccountInput(rawNumber) : null;
if (!number) {
throw new Error("invalid Signal account");
}
return number;
}),
),
].toSorted();
}
function parseSignalLinkUri(value: unknown): string {
const rawUri = isRecord(value) ? value.deviceLinkUri : undefined;
const uri = typeof rawUri === "string" ? rawUri.trim() : "";
if (!uri || uri !== rawUri || uri.length > SIGNAL_LINK_URI_MAX_LENGTH) {
throw new Error("invalid Signal device link URI");
}
const parsed = new URL(uri);
if (parsed.protocol !== "sgnl:" || parsed.hostname !== "linkdevice") {
throw new Error("invalid Signal device link URI");
}
return uri;
}
function parseLinkedSignalNumber(value: unknown): string {
const rawNumber = isRecord(value) ? value.number : undefined;
const number = typeof rawNumber === "string" ? normalizeSignalAccountInput(rawNumber) : null;
if (!number) {
throw new Error("invalid linked Signal account");
}
return number;
}
async function noteSignalLinkFallback(prompter: WizardPrompter) {
await prompter.note(
[
"Automatic Signal linking could not complete. Continue with the account number, then link it manually:",
formatCliCommand('signal-cli link -n "OpenClaw"'),
].join("\n"),
"Signal",
);
}
async function prepareManagedSignalLink(params: {
cfg: OpenClawConfig;
accountId: string;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: { signal?: AbortSignal };
cliPath: string;
}): Promise<string | undefined> {
const signal = params.options?.signal;
if (
!signal ||
!params.prompter.qrCode ||
listSignalAccountIds(params.cfg).some(
(accountId) => resolveSignalAccount({ cfg: params.cfg, accountId }).configured,
)
) {
return undefined;
}
const transport = resolveSignalAccount({
cfg: params.cfg,
accountId: params.accountId,
}).transport;
if (transport.kind !== "managed-native") {
return undefined;
}
signal.throwIfAborted();
const lifecycle = createSignalDaemonLifecycle({ abortSignal: signal });
const onAbort = () => void lifecycle.stop();
signal.addEventListener("abort", onAbort, { once: true });
try {
let accounts: string[];
let deviceLinkUri: string | undefined;
try {
const daemon = spawnSignalDaemon({
cliPath: params.cliPath,
...(transport.configPath ? { configPath: transport.configPath } : {}),
httpHost: transport.httpHost,
httpPort: transport.httpPort,
receiveMode: "manual",
});
lifecycle.attach(daemon);
await waitForSignalDaemonReady({
baseUrl: transport.baseUrl,
abortSignal: lifecycle.abortSignal,
timeoutMs: transport.startupTimeoutMs,
logAfterMs: transport.startupTimeoutMs,
runtime: { ...params.runtime, log: () => {}, error: () => {} },
});
accounts = parseSignalAccounts(
await signalRpcRequest("listAccounts", undefined, {
baseUrl: transport.baseUrl,
maxResponseBytes: SIGNAL_LINK_RPC_MAX_BYTES,
}),
);
if (accounts.length === 0) {
deviceLinkUri = parseSignalLinkUri(
await signalRpcRequest("startLink", undefined, {
baseUrl: transport.baseUrl,
timeoutMs: 35_000,
maxResponseBytes: SIGNAL_LINK_RPC_MAX_BYTES,
}),
);
}
} catch {
if (signal.aborted) {
throw new WizardCancelledError();
}
await noteSignalLinkFallback(params.prompter);
return undefined;
}
if (accounts.length > 0) {
return accounts.length === 1
? accounts[0]
: await params.prompter.select({
message: "Choose the Signal account for OpenClaw",
options: accounts.map((number) => ({ value: number, label: number })),
});
}
if (!deviceLinkUri) {
await noteSignalLinkFallback(params.prompter);
return undefined;
}
signal.throwIfAborted();
try {
const settled = signalRpcRequest(
"finishLink",
{ deviceLinkUri, deviceName: "OpenClaw" },
{
baseUrl: transport.baseUrl,
timeoutMs: SIGNAL_LINK_EXPIRES_IN_MS + 5_000,
maxResponseBytes: SIGNAL_LINK_RPC_MAX_BYTES,
},
).then(parseLinkedSignalNumber);
return await params.prompter.qrCode({
title: "Link Signal",
message: "In Signal, open Settings → Linked devices and scan this QR code.",
text: deviceLinkUri,
expiresInMs: SIGNAL_LINK_EXPIRES_IN_MS,
settled,
});
} catch {
if (signal.aborted) {
throw new WizardCancelledError();
}
await noteSignalLinkFallback(params.prompter);
return undefined;
}
} finally {
signal.removeEventListener("abort", onAbort);
await lifecycle.stop();
}
}
export const signalSetupWizard: ChannelSetupWizard = {
channel,
status: {
@@ -74,36 +254,57 @@ export const signalSetupWizard: ChannelSetupWizard = {
if (transport.kind !== "managed-native") {
return undefined;
}
const currentCliPath =
let cliPath =
(typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ??
(transport.kind === "managed-native" ? transport.cliPath : undefined) ??
"signal-cli";
const cliDetected = await detectBinary(currentCliPath);
const cliDetected = await detectBinary(cliPath);
const wantsInstall = await prompter.confirm({
message: cliDetected ? t("wizard.signal.reinstallPrompt") : t("wizard.signal.installPrompt"),
initialValue: !cliDetected,
});
if (!wantsInstall) {
if (!wantsInstall && !cliDetected) {
return undefined;
}
try {
await options?.beforePersistentEffect?.();
const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) {
await prompter.note(`Installed signal-cli at ${result.cliPath}`, "Signal");
return {
credentialValues: {
cliPath: result.cliPath,
},
};
const preparedCredentialValues: Record<string, string> = {};
if (wantsInstall) {
try {
await options?.beforePersistentEffect?.();
const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) {
cliPath = result.cliPath;
preparedCredentialValues.cliPath = cliPath;
await prompter.note(`Installed signal-cli at ${cliPath}`, "Signal");
} else if (!result.ok) {
await prompter.note(
"signal-cli installation failed. Install it manually and retry setup.",
"Signal",
);
return undefined;
}
} catch {
await prompter.note(
"signal-cli installation failed. Install it manually and retry setup.",
"Signal",
);
return undefined;
}
if (!result.ok) {
await prompter.note(result.error ?? "signal-cli install failed.", "Signal");
}
} catch (error) {
await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal");
}
return undefined;
const linkedNumber = await prepareManagedSignalLink({
cfg,
accountId,
runtime,
prompter,
options,
cliPath,
});
if (linkedNumber) {
preparedCredentialValues.signalNumber = linkedNumber;
preparedCredentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] = "true";
}
return Object.keys(preparedCredentialValues).length > 0
? { credentialValues: preparedCredentialValues }
: undefined;
},
credentials: [],
textInputs: [