From 23a582188479ffa4342579731ce9939131fddd88 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 15 Feb 2026 18:35:13 +0000 Subject: [PATCH] CLI: restore qr --remote --- src/cli/qr-cli.test.ts | 164 ++++++++++++++++++++++++++++++++++++++++ src/cli/qr-cli.ts | 167 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/cli/qr-cli.test.ts create mode 100644 src/cli/qr-cli.ts diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts new file mode 100644 index 00000000000..32f01b34d38 --- /dev/null +++ b/src/cli/qr-cli.test.ts @@ -0,0 +1,164 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { encodePairingSetupCode } from "../pairing/setup-code.js"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +const loadConfig = vi.fn(); +const runCommandWithTimeout = vi.fn(); +const qrGenerate = vi.fn((_input, _opts, cb: (output: string) => void) => { + cb("ASCII-QR"); +}); + +vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); +vi.mock("../config/config.js", () => ({ loadConfig })); +vi.mock("../process/exec.js", () => ({ runCommandWithTimeout })); +vi.mock("qrcode-terminal", () => ({ + default: { + generate: qrGenerate, + }, +})); + +const { registerQrCli } = await import("./qr-cli.js"); + +describe("registerQrCli", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prints setup code only when requested", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + await program.parseAsync(["qr", "--setup-code-only"], { from: "user" }); + + const expected = encodePairingSetupCode({ + url: "ws://gateway.local:18789", + token: "tok", + }); + expect(runtime.log).toHaveBeenCalledWith(expected); + expect(qrGenerate).not.toHaveBeenCalled(); + }); + + it("renders ASCII QR by default", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + await program.parseAsync(["qr"], { from: "user" }); + + expect(qrGenerate).toHaveBeenCalledTimes(1); + const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(output).toContain("Pairing QR"); + expect(output).toContain("ASCII-QR"); + expect(output).toContain("Gateway:"); + expect(output).toContain("openclaw devices approve "); + }); + + it("accepts --token override when config has no auth", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + }, + }); + + const program = new Command(); + registerQrCli(program); + + await program.parseAsync(["qr", "--setup-code-only", "--token", "override-token"], { + from: "user", + }); + + const expected = encodePairingSetupCode({ + url: "ws://gateway.local:18789", + token: "override-token", + }); + expect(runtime.log).toHaveBeenCalledWith(expected); + }); + + it("exits with error when gateway config is not pairable", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "loopback", + auth: { mode: "token", token: "tok" }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + await expect(program.parseAsync(["qr"], { from: "user" })).rejects.toThrow("exit"); + + const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(output).toContain("only bound to loopback"); + }); + + it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => { + loadConfig.mockReturnValue({ + gateway: { + remote: { url: "wss://remote.example.com:444" }, + auth: { mode: "token", token: "tok" }, + }, + plugins: { + entries: { + "device-pair": { + config: { + publicUrl: "ws://plugin.example.com:18789", + }, + }, + }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + await program.parseAsync(["qr", "--setup-code-only", "--remote"], { from: "user" }); + + const expected = encodePairingSetupCode({ + url: "wss://remote.example.com:444", + token: "tok", + }); + expect(runtime.log).toHaveBeenCalledWith(expected); + }); + + it("errors when --remote is set but no remote URL is configured", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + await expect(program.parseAsync(["qr", "--remote"], { from: "user" })).rejects.toThrow("exit"); + + const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(output).toContain("qr --remote requires"); + }); +}); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts new file mode 100644 index 00000000000..aef119aa779 --- /dev/null +++ b/src/cli/qr-cli.ts @@ -0,0 +1,167 @@ +import type { Command } from "commander"; +import qrcode from "qrcode-terminal"; +import { loadConfig } from "../config/config.js"; +import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { defaultRuntime } from "../runtime.js"; +import { theme } from "../terminal/theme.js"; + +type QrCliOptions = { + json?: boolean; + setupCodeOnly?: boolean; + ascii?: boolean; + remote?: boolean; + url?: string; + publicUrl?: string; + token?: string; + password?: string; +}; + +function renderQrAscii(data: string): Promise { + return new Promise((resolve) => { + qrcode.generate(data, { small: true }, (output: string) => { + resolve(output); + }); + }); +} + +function readDevicePairPublicUrlFromConfig(cfg: ReturnType): string | undefined { + const value = cfg.plugins?.entries?.["device-pair"]?.config?.["publicUrl"]; + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function registerQrCli(program: Command) { + program + .command("qr") + .description("Generate an iOS pairing QR code and setup code") + .option( + "--remote", + "Prefer gateway.remote.url (or tailscale serve/funnel) for the setup payload (ignores device-pair publicUrl)", + false, + ) + .option("--url ", "Override gateway URL used in the setup payload") + .option("--public-url ", "Override gateway public URL used in the setup payload") + .option("--token ", "Override gateway token for setup payload") + .option("--password ", "Override gateway password for setup payload") + .option("--setup-code-only", "Print only the setup code", false) + .option("--no-ascii", "Skip ASCII QR rendering") + .option("--json", "Output JSON", false) + .action(async (opts: QrCliOptions) => { + try { + if (opts.token && opts.password) { + throw new Error("Use either --token or --password, not both."); + } + + const loaded = loadConfig(); + const cfg = { + ...loaded, + gateway: { + ...loaded.gateway, + auth: { + ...loaded.gateway?.auth, + }, + }, + }; + + const token = typeof opts.token === "string" ? opts.token.trim() : ""; + const password = typeof opts.password === "string" ? opts.password.trim() : ""; + if (token) { + cfg.gateway.auth.mode = "token"; + cfg.gateway.auth.token = token; + } + if (password) { + cfg.gateway.auth.mode = "password"; + cfg.gateway.auth.password = password; + } + + const wantsRemote = opts.remote === true; + if (wantsRemote && !opts.url && !opts.publicUrl) { + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const remoteUrl = cfg.gateway?.remote?.url; + const hasRemoteUrl = typeof remoteUrl === "string" && remoteUrl.trim().length > 0; + const hasTailscaleServe = tailscaleMode === "serve" || tailscaleMode === "funnel"; + if (!hasRemoteUrl && !hasTailscaleServe) { + throw new Error( + "qr --remote requires gateway.remote.url (or gateway.tailscale.mode=serve/funnel).", + ); + } + } + + const publicUrl = + typeof opts.url === "string" && opts.url.trim() + ? opts.url.trim() + : typeof opts.publicUrl === "string" && opts.publicUrl.trim() + ? opts.publicUrl.trim() + : wantsRemote + ? undefined + : readDevicePairPublicUrlFromConfig(cfg); + + const resolved = await resolvePairingSetupFromConfig(cfg, { + publicUrl, + forceSecure: wantsRemote || undefined, + runCommandWithTimeout: async (argv, runOpts) => + await runCommandWithTimeout(argv, { + timeoutMs: runOpts.timeoutMs, + }), + }); + + if (!resolved.ok) { + throw new Error(resolved.error); + } + + const setupCode = encodePairingSetupCode(resolved.payload); + + if (opts.setupCodeOnly) { + defaultRuntime.log(setupCode); + return; + } + + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { + setupCode, + gatewayUrl: resolved.payload.url, + auth: resolved.authLabel, + urlSource: resolved.urlSource, + }, + null, + 2, + ), + ); + return; + } + + const lines: string[] = [ + theme.heading("Pairing QR"), + "Scan this with the OpenClaw iOS app (Onboarding -> Scan QR).", + "", + ]; + + if (opts.ascii !== false) { + const qrAscii = await renderQrAscii(setupCode); + lines.push(qrAscii.trimEnd(), ""); + } + + lines.push( + `${theme.muted("Setup code:")} ${setupCode}`, + `${theme.muted("Gateway:")} ${resolved.payload.url}`, + `${theme.muted("Auth:")} ${resolved.authLabel}`, + `${theme.muted("Source:")} ${resolved.urlSource}`, + "", + "Approve after scan with:", + ` ${theme.command("openclaw devices list")}`, + ` ${theme.command("openclaw devices approve ")}`, + ); + + defaultRuntime.log(lines.join("\n")); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +}