mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
CLI/Gateway: restore qr flow with --remote support (clean) (#18091)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 4bee77ce06
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
7
src/cli/clawbot-cli.ts
Normal file
7
src/cli/clawbot-cli.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { registerQrCli } from "./qr-cli.js";
|
||||
|
||||
export function registerClawbotCli(program: Command) {
|
||||
const clawbot = program.command("clawbot").description("Legacy clawbot command aliases");
|
||||
registerQrCli(clawbot);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ describe("registerSubCliCommands", () => {
|
||||
const names = program.commands.map((cmd) => cmd.name());
|
||||
expect(names).toContain("acp");
|
||||
expect(names).toContain("gateway");
|
||||
expect(names).toContain("clawbot");
|
||||
expect(registerAcpCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,22 @@ const entries: SubCliEntry[] = [
|
||||
mod.registerWebhooksCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "qr",
|
||||
description: "Generate iOS pairing QR/setup code",
|
||||
register: async (program) => {
|
||||
const mod = await import("../qr-cli.js");
|
||||
mod.registerQrCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clawbot",
|
||||
description: "Legacy clawbot command aliases",
|
||||
register: async (program) => {
|
||||
const mod = await import("../clawbot-cli.js");
|
||||
mod.registerClawbotCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pairing",
|
||||
description: "Pairing helpers",
|
||||
|
||||
213
src/cli/qr-cli.test.ts
Normal file
213
src/cli/qr-cli.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
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", token: "remote-tok" },
|
||||
auth: { mode: "token", token: "local-tok" },
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"device-pair": {
|
||||
config: {
|
||||
publicUrl: "wss://wrong.example.com:443",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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: "remote-tok",
|
||||
});
|
||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it("reports gateway.remote.url as source in --remote json output", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
|
||||
auth: { mode: "token", token: "local-tok" },
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"device-pair": {
|
||||
config: {
|
||||
publicUrl: "wss://wrong.example.com:443",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
registerQrCli(program);
|
||||
await program.parseAsync(["qr", "--json", "--remote"], { from: "user" });
|
||||
|
||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
||||
setupCode?: string;
|
||||
gatewayUrl?: string;
|
||||
auth?: string;
|
||||
urlSource?: string;
|
||||
};
|
||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||
expect(payload.auth).toBe("token");
|
||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
||||
});
|
||||
|
||||
it("prefers gateway.remote.url over tailscale when --remote is set", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
tailscale: { mode: "serve" },
|
||||
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
|
||||
auth: { mode: "token", token: "local-tok" },
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"device-pair": {
|
||||
config: {
|
||||
publicUrl: "wss://wrong.example.com:443",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
registerQrCli(program);
|
||||
await program.parseAsync(["qr", "--json", "--remote"], { from: "user" });
|
||||
|
||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
||||
gatewayUrl?: string;
|
||||
urlSource?: string;
|
||||
};
|
||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
||||
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
172
src/cli/qr-cli.ts
Normal file
172
src/cli/qr-cli.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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",
|
||||
"Use gateway.remote.url and gateway.remote token/password (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() : "";
|
||||
const wantsRemote = opts.remote === true;
|
||||
if (token) {
|
||||
cfg.gateway.auth.mode = "token";
|
||||
cfg.gateway.auth.token = token;
|
||||
}
|
||||
if (password) {
|
||||
cfg.gateway.auth.mode = "password";
|
||||
cfg.gateway.auth.password = password;
|
||||
}
|
||||
if (wantsRemote && !token && !password) {
|
||||
const remoteToken =
|
||||
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
|
||||
const remotePassword =
|
||||
typeof cfg.gateway?.remote?.password === "string"
|
||||
? cfg.gateway.remote.password.trim()
|
||||
: "";
|
||||
if (remoteToken) {
|
||||
cfg.gateway.auth.mode = "token";
|
||||
cfg.gateway.auth.token = remoteToken;
|
||||
cfg.gateway.auth.password = undefined;
|
||||
} else if (remotePassword) {
|
||||
cfg.gateway.auth.mode = "password";
|
||||
cfg.gateway.auth.password = remotePassword;
|
||||
cfg.gateway.auth.token = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const explicitUrl =
|
||||
typeof opts.url === "string" && opts.url.trim()
|
||||
? opts.url.trim()
|
||||
: typeof opts.publicUrl === "string" && opts.publicUrl.trim()
|
||||
? opts.publicUrl.trim()
|
||||
: undefined;
|
||||
const publicUrl =
|
||||
explicitUrl ?? (wantsRemote ? undefined : readDevicePairPublicUrlFromConfig(cfg));
|
||||
|
||||
const resolved = await resolvePairingSetupFromConfig(cfg, {
|
||||
publicUrl,
|
||||
preferRemoteUrl: wantsRemote,
|
||||
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