mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
CLI: restore qr --remote
This commit is contained in:
164
src/cli/qr-cli.test.ts
Normal file
164
src/cli/qr-cli.test.ts
Normal file
@@ -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 <requestId>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
167
src/cli/qr-cli.ts
Normal file
167
src/cli/qr-cli.ts
Normal file
@@ -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<string> {
|
||||
return new Promise((resolve) => {
|
||||
qrcode.generate(data, { small: true }, (output: string) => {
|
||||
resolve(output);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readDevicePairPublicUrlFromConfig(cfg: ReturnType<typeof loadConfig>): 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 <url>", "Override gateway URL used in the setup payload")
|
||||
.option("--public-url <url>", "Override gateway public URL used in the setup payload")
|
||||
.option("--token <token>", "Override gateway token for setup payload")
|
||||
.option("--password <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 <requestId>")}`,
|
||||
);
|
||||
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user