diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fe0582085..6ac77ce05cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc. - Auto-reply/system events: route async exec-event completion replies through the persisted session delivery context, so long-running command results return to the originating channel instead of being dropped when live origin metadata is missing. (#70258) Thanks @wzfukui. - OpenAI/image generation: send reference-image edits as guarded multipart uploads instead of JSON data URLs, restoring complex multi-reference `gpt-image-2` edits. Fixes #70642. Thanks @dashhuang. - QA channel/security: reject non-HTTP(S) inbound attachment URLs before media fetch, and log rejected schemes so suspicious or misconfigured payloads are visible during debugging. (#70708) Thanks @vincentkoc. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 8e9cabfcb4b..d5fbe783247 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -16,7 +16,7 @@ Feishu/Lark is an all-in-one collaboration platform where teams chat, share docu ## Quick start -> **Requires OpenClaw 2026.4.10 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`. +> **Requires OpenClaw 2026.4.23 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 7d3faf9bb28..75056bc9dde 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -258,6 +258,11 @@ const kind = api.runtime.media.mediaKindFromMime("image/jpeg"); // "image" const isVoice = api.runtime.media.isVoiceCompatibleAudio(filePath); const metadata = await api.runtime.media.getImageMetadata(filePath); const resized = await api.runtime.media.resizeToJpeg(buffer, { maxWidth: 800 }); +const terminalQr = await api.runtime.media.renderQrTerminal("https://openclaw.ai"); +const pngQr = await api.runtime.media.renderQrPngBase64("https://openclaw.ai", { + scale: 6, // 1-12 + marginModules: 4, // 0-16 +}); ``` ### `api.runtime.config` diff --git a/docs/reference/test.md b/docs/reference/test.md index e12b35ce0fe..112043702c7 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -124,7 +124,7 @@ This script drives the interactive wizard via a pseudo-tty, verifies config/work ## QR import smoke (Docker) -Ensures `qrcode-terminal` loads under the supported Docker Node runtimes (Node 24 default, Node 22 compatible): +Ensures the maintained QR runtime helper loads under the supported Docker Node runtimes (Node 24 default, Node 22 compatible): ```bash pnpm test:docker:qr diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 3e2f397750a..483e47a1a63 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -5,7 +5,6 @@ "type": "module", "dependencies": { "@larksuiteoapi/node-sdk": "^1.61.1", - "qrcode-terminal": "^0.12.0", "typebox": "1.1.28" }, "devDependencies": { @@ -13,7 +12,7 @@ "openclaw": "workspace:*" }, "peerDependencies": { - "openclaw": ">=2026.4.20" + "openclaw": ">=2026.4.23" }, "peerDependenciesMeta": { "openclaw": { @@ -41,10 +40,10 @@ "install": { "npmSpec": "@openclaw/feishu", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" + "minHostVersion": ">=2026.4.23" }, "compat": { - "pluginApi": ">=2026.4.20" + "pluginApi": ">=2026.4.23" }, "build": { "openclawVersion": "2026.4.20" diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts index 319f2e7f287..af7463735cd 100644 --- a/extensions/feishu/src/app-registration.ts +++ b/extensions/feishu/src/app-registration.ts @@ -5,8 +5,8 @@ * Replaces axios with native fetch, removes inquirer/ora/chalk in favor of * the openclaw WizardPrompter surface. */ - import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { renderQrTerminal } from "./qr-terminal.js"; import type { FeishuDomain } from "./types.js"; // --------------------------------------------------------------------------- @@ -252,9 +252,8 @@ export async function pollAppRegistration(params: { * otherwise the pattern is corrupted and cannot be scanned. */ export async function printQrCode(url: string): Promise { - const mod = await import("qrcode-terminal"); - const qrcode = mod.default ?? mod; - qrcode.generate(url, { small: true }); + const output = await renderQrTerminal(url, { small: true }); + process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); } /** diff --git a/extensions/feishu/src/qr-terminal.ts b/extensions/feishu/src/qr-terminal.ts new file mode 100644 index 00000000000..f8aa1b9b6c1 --- /dev/null +++ b/extensions/feishu/src/qr-terminal.ts @@ -0,0 +1 @@ +export { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/feishu/src/qrcode-terminal.d.ts b/extensions/feishu/src/qrcode-terminal.d.ts deleted file mode 100644 index 9574b3ca7ca..00000000000 --- a/extensions/feishu/src/qrcode-terminal.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module "qrcode-terminal" { - type GenerateOptions = { - small?: boolean; - }; - - type QrCodeTerminal = { - generate: (input: string, options?: GenerateOptions, cb?: (output: string) => void) => void; - }; - - const qrcode: QrCodeTerminal; - export default qrcode; -} diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index fd895144296..d77a2ff3f58 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -6,7 +6,6 @@ "dependencies": { "@whiskeysockets/baileys": "7.0.0-rc.9", "jimp": "^1.6.1", - "qrcode-terminal": "^0.12.0", "typebox": "1.1.28", "undici": "8.1.0" }, @@ -15,7 +14,7 @@ "openclaw": "workspace:*" }, "peerDependencies": { - "openclaw": ">=2026.4.20" + "openclaw": ">=2026.4.23" }, "peerDependenciesMeta": { "openclaw": { @@ -54,10 +53,10 @@ "install": { "npmSpec": "@openclaw/whatsapp", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" + "minHostVersion": ">=2026.4.23" }, "compat": { - "pluginApi": ">=2026.4.20" + "pluginApi": ">=2026.4.23" }, "bundle": { "stageRuntimeDependencies": true diff --git a/extensions/whatsapp/src/login.test.ts b/extensions/whatsapp/src/login.test.ts index deb640cfe89..8ec6c5cb274 100644 --- a/extensions/whatsapp/src/login.test.ts +++ b/extensions/whatsapp/src/login.test.ts @@ -1,6 +1,4 @@ import { EventEmitter } from "node:events"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderQrPngBase64 } from "./qr-image.js"; @@ -88,13 +86,4 @@ describe("renderQrPngBase64", () => { const buf = Buffer.from(b64, "base64"); expect(buf.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); }); - - it("avoids dynamic require of qrcode-terminal vendor modules", async () => { - const sourcePath = resolve(process.cwd(), "src/media/qr-image.ts"); - const source = await readFile(sourcePath, "utf-8"); - expect(source).not.toContain("createRequire("); - expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")'); - expect(source).toContain("qrcode-terminal/vendor/QRCode/index.js"); - expect(source).toContain("qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"); - }); }); diff --git a/extensions/whatsapp/src/qr-terminal.ts b/extensions/whatsapp/src/qr-terminal.ts new file mode 100644 index 00000000000..f8aa1b9b6c1 --- /dev/null +++ b/extensions/whatsapp/src/qr-terminal.ts @@ -0,0 +1 @@ +export { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/whatsapp/src/qrcode-terminal.d.ts b/extensions/whatsapp/src/qrcode-terminal.d.ts deleted file mode 100644 index 9574b3ca7ca..00000000000 --- a/extensions/whatsapp/src/qrcode-terminal.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module "qrcode-terminal" { - type GenerateOptions = { - small?: boolean; - }; - - type QrCodeTerminal = { - generate: (input: string, options?: GenerateOptions, cb?: (output: string) => void) => void; - }; - - const qrcode: QrCodeTerminal; - export default qrcode; -} diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index f726bec1c7c..3fea5bab278 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -21,6 +21,7 @@ import { writeCredsJsonAtomically, type CredsQueueWaitResult, } from "./creds-persistence.js"; +import { renderQrTerminal } from "./qr-terminal.js"; import { formatError, getStatusCode } from "./session-errors.js"; import { DisconnectReason, @@ -60,11 +61,6 @@ const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; const CREDS_FLUSH_TIMEOUT_MESSAGE = "Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds."; -async function loadQrTerminal() { - const mod = await import("qrcode-terminal"); - return mod.default ?? mod; -} - function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, @@ -113,6 +109,11 @@ async function safeSaveCreds( } } +async function printTerminalQr(qr: string): Promise { + const output = await renderQrTerminal(qr, { small: true }); + process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); +} + /** * Create a Baileys socket backed by the multi-file auth store we keep on disk. * Consumers can opt into QR printing for interactive login flows. @@ -172,8 +173,9 @@ export async function createWaSocket( opts.onQr?.(qr); if (printQr) { console.log("Scan this QR in WhatsApp (Linked Devices):"); - const qrcode = await loadQrTerminal(); - qrcode.generate(qr, { small: true }); + void printTerminalQr(qr).catch((err) => { + sessionLogger.warn({ error: String(err) }, "failed rendering WhatsApp QR"); + }); } } if (connection === "close") { diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 13851f66c03..03b57edd850 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -610,9 +610,8 @@ vi.mock("./session.runtime.js", () => { }; }); -vi.mock("qrcode-terminal", () => ({ - default: { generate: vi.fn() }, - generate: vi.fn(), +vi.mock("./qr-terminal.js", () => ({ + renderQrTerminal: vi.fn(async () => "ASCII-QR"), })); export const baileys = await import("./session.runtime.js"); diff --git a/package.json b/package.json index 6e5b62e2fdf..ee7e4f956ce 100644 --- a/package.json +++ b/package.json @@ -1553,6 +1553,7 @@ "@mariozechner/pi-tui": "0.69.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", + "@vincentkoc/qrcode-tui": "0.2.1", "ajv": "^8.18.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -1573,7 +1574,6 @@ "osc-progress": "^0.3.0", "pdfjs-dist": "^5.6.205", "proxy-agent": "^8.0.1", - "qrcode-terminal": "^0.12.0", "semver": "7.7.4", "sharp": "^0.34.5", "sqlite-vec": "0.1.9", @@ -1594,7 +1594,6 @@ "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", "@types/node": "25.6.0", - "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260423.1", "@vitest/coverage-v8": "^4.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbfd973dbfb..ca40ce5fea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@napi-rs/canvas': specifier: ^0.1.89 version: 0.1.92 + '@vincentkoc/qrcode-tui': + specifier: 0.2.1 + version: 0.2.1 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -138,9 +141,6 @@ importers: proxy-agent: specifier: ^8.0.1 version: 8.0.1 - qrcode-terminal: - specifier: ^0.12.0 - version: 0.12.0 semver: specifier: 7.7.4 version: 7.7.4 @@ -196,9 +196,6 @@ importers: '@types/node': specifier: 25.6.0 version: 25.6.0 - '@types/qrcode-terminal': - specifier: ^0.12.2 - version: 0.12.2 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -547,9 +544,6 @@ importers: '@larksuiteoapi/node-sdk': specifier: ^1.61.1 version: 1.61.1 - qrcode-terminal: - specifier: ^0.12.0 - version: 0.12.0 typebox: specifier: 1.1.28 version: 1.1.28 @@ -1343,9 +1337,6 @@ importers: jimp: specifier: ^1.6.1 version: 1.6.1 - qrcode-terminal: - specifier: ^0.12.0 - version: 0.12.0 typebox: specifier: 1.1.28 version: 1.1.28 @@ -4197,9 +4188,6 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/qrcode-terminal@0.12.2': - resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} - '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -4291,6 +4279,11 @@ packages: resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==} engines: {node: '>=16', npm: '>=8'} + '@vincentkoc/qrcode-tui@0.2.1': + resolution: {integrity: sha512-F2XVHMfasJ0q8G93gtcyU9Px0wMH6o6nIZLrZYSHc6dm9Pq3oCbHuVYYG/UQvJD0rhrGH3P9B6qgpCAqSDUw5w==} + engines: {node: '>=20'} + hasBin: true + '@vitest/browser-playwright@4.1.4': resolution: {integrity: sha512-q3PchVhZINX23Pv+RERgAtDlp6wzVkID/smOPnZ5YGWpeWUe3jMNYppeVh15j4il3G7JIJty1d1Kicpm0HSMig==} peerDependencies: @@ -4699,6 +4692,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4772,6 +4769,9 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -4917,6 +4917,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -4982,6 +4986,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + discord-api-types@0.38.45: resolution: {integrity: sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==} @@ -5274,6 +5281,10 @@ packages: resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} engines: {node: '>=4.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + flatbuffers@24.12.23: resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} @@ -5907,6 +5918,10 @@ packages: lit@3.3.2: resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -6456,6 +6471,14 @@ packages: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-queue@6.6.2: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} @@ -6484,6 +6507,10 @@ packages: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + pac-proxy-agent@7.2.0: resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} engines: {node: '>= 14'} @@ -6547,6 +6574,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-expression-matcher@1.5.0: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} @@ -6618,6 +6649,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -6758,6 +6793,11 @@ packages: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -6870,6 +6910,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -7631,6 +7674,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7660,6 +7706,10 @@ packages: resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} engines: {node: '>=12.17'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -7701,6 +7751,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -7717,6 +7770,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -7725,6 +7782,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -11095,8 +11156,6 @@ snapshots: dependencies: undici-types: 7.19.2 - '@types/qrcode-terminal@0.12.2': {} - '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -11172,6 +11231,10 @@ snapshots: '@urbit/aura@3.0.0': {} + '@vincentkoc/qrcode-tui@0.2.1': + dependencies: + qrcode: 1.5.4 + '@vitest/browser-playwright@4.1.4(playwright@1.59.1)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': dependencies: '@vitest/browser': 4.1.4(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) @@ -11619,6 +11682,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase@5.3.1: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -11681,6 +11746,12 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -11820,6 +11891,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -11871,6 +11944,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + discord-api-types@0.38.45: {} discord-api-types@0.38.47: {} @@ -12220,6 +12295,11 @@ snapshots: dependencies: array-back: 3.1.0 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + flatbuffers@24.12.23: {} follow-redirects@1.16.0: {} @@ -13019,6 +13099,10 @@ snapshots: lit-element: 4.2.2 lit-html: 3.3.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.camelcase@4.3.0: {} lodash.clonedeep@4.5.0: {} @@ -13811,6 +13895,14 @@ snapshots: p-finally@1.0.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-queue@6.6.2: dependencies: eventemitter3: 4.0.7 @@ -13838,6 +13930,8 @@ snapshots: p-timeout@7.0.1: {} + p-try@2.2.0: {} + pac-proxy-agent@7.2.0: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 @@ -13918,6 +14012,8 @@ snapshots: partial-json@0.1.7: {} + path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} path-is-absolute@1.0.1: @@ -13983,6 +14079,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + pngjs@6.0.0: {} pngjs@7.0.0: {} @@ -14165,6 +14263,12 @@ snapshots: qrcode-terminal@0.12.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -14311,6 +14415,8 @@ snapshots: transitivePeerDependencies: - supports-color + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -14448,8 +14554,7 @@ snapshots: transitivePeerDependencies: - supports-color - set-blocking@2.0.0: - optional: true + set-blocking@2.0.0: {} setimmediate@1.0.5: {} @@ -15089,6 +15194,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -15118,6 +15225,12 @@ snapshots: wordwrapjs@5.1.1: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -15145,6 +15258,8 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@4.0.0: {} @@ -15153,10 +15268,29 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import index bd23478d142..0d7be4798af 100644 --- a/scripts/e2e/Dockerfile.qr-import +++ b/scripts/e2e/Dockerfile.qr-import @@ -17,7 +17,7 @@ COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY --chown=appuser:appuser ui/package.json ./ui/package.json COPY --chown=appuser:appuser patches ./patches -# This image only exercises the root qrcode-terminal dependency path. +# This image only exercises the root QR runtime dependency path. # Keep the pre-install copy set limited to the manifests needed for root # workspace resolution so unrelated extension edits do not bust the layer. ARG OPENCLAW_QR_INSTALL_CACHE_BUSTER=stable diff --git a/scripts/e2e/qr-import-docker.sh b/scripts/e2e/qr-import-docker.sh index 7f1df4caa6b..3aa3e00148c 100755 --- a/scripts/e2e/qr-import-docker.sh +++ b/scripts/e2e/qr-import-docker.sh @@ -26,5 +26,5 @@ DOCKER_BUILD_CMD+=( ) run_logged qr-import-build "${DOCKER_BUILD_CMD[@]}" -echo "Running qrcode-terminal import smoke..." -run_logged qr-import-run docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))" +echo "Running qrcode-tui import smoke..." +run_logged qr-import-run docker run --rm -t "$IMAGE_NAME" node -e "import('@vincentkoc/qrcode-tui').then(async (m)=>{process.stdout.write(await m.renderTerminal('qr-smoke',{small:true}))})" diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index ac89cb8857f..2d1d4895774 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -10,9 +10,7 @@ const mocks = vi.hoisted(() => ({ resolvedConfig: config, diagnostics: [] as string[], })), - qrGenerate: vi.fn((_input: unknown, _opts: unknown, cb: (output: string) => void) => { - cb("ASCII-QR"); - }), + renderTerminal: vi.fn(async () => "ASCII-QR"), })); const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); const runtimeLog = runtime.log; @@ -27,6 +25,9 @@ vi.mock("../runtime.js", async () => { }); vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig })); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout })); +vi.mock("../media/qr-terminal.ts", () => ({ + renderQrTerminal: mocks.renderTerminal, +})); vi.mock("./command-secret-gateway.js", () => ({ resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, })); @@ -36,16 +37,10 @@ vi.mock("../infra/device-bootstrap.js", () => ({ expiresAtMs: 123, })), })); -vi.mock("qrcode-terminal", () => ({ - default: { - generate: mocks.qrGenerate, - }, -})); - const loadConfig = mocks.loadConfig; const runCommandWithTimeout = mocks.runCommandWithTimeout; const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway; -const qrGenerate = mocks.qrGenerate; +const renderTerminal = mocks.renderTerminal; const { registerQrCli } = await import("./qr-cli.js"); @@ -196,7 +191,7 @@ describe("registerQrCli", () => { bootstrapToken: "bootstrap-123", }); expect(runtime.log).toHaveBeenCalledWith(expected); - expect(qrGenerate).not.toHaveBeenCalled(); + expect(renderTerminal).not.toHaveBeenCalled(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -211,7 +206,7 @@ describe("registerQrCli", () => { await runQr([]); - expect(qrGenerate).toHaveBeenCalledTimes(1); + expect(renderTerminal).toHaveBeenCalledTimes(1); const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("Pairing QR"); expect(output).toContain("ASCII-QR"); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 40332ecff64..1f65ae82987 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { trimToUndefined } from "../gateway/credentials.js"; import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; +import { renderQrTerminal } from "../media/qr-terminal.ts"; import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime } from "../runtime.js"; @@ -23,20 +24,9 @@ type QrCliOptions = { password?: string; }; -async function loadQrTerminal() { - const mod = await import("qrcode-terminal"); - return mod.default ?? mod; +function renderQrAscii(data: string): Promise { + return renderQrTerminal(data, { small: true }); } - -async function renderQrAscii(data: string): Promise { - const qrcode = await loadQrTerminal(); - return new Promise((resolve) => { - qrcode.generate(data, { small: true }, (output: string) => { - resolve(output); - }); - }); -} - function readDevicePairPublicUrlFromConfig(cfg: OpenClawConfig): string | undefined { const value = cfg.plugins?.entries?.["device-pair"]?.config?.["publicUrl"]; if (typeof value !== "string") { diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 19e6c83d02a..ea2cc6768fa 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -138,9 +138,7 @@ describe("docker build cache layout", () => { /^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m, ), ).toBeLessThan(installIndex); - expect(dockerfile).toContain( - "This image only exercises the root qrcode-terminal dependency path.", - ); + expect(dockerfile).toContain("This image only exercises the root QR runtime dependency path."); expect( indexOfPattern( dockerfile, diff --git a/src/media/qr-image.test.ts b/src/media/qr-image.test.ts new file mode 100644 index 00000000000..54537e4613c --- /dev/null +++ b/src/media/qr-image.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const renderPngBase64 = vi.hoisted(() => vi.fn(async () => "mocked-base64")); + +vi.mock("@vincentkoc/qrcode-tui", () => ({ + renderPngBase64, +})); + +import { renderQrPngBase64 } from "./qr-image.ts"; + +describe("renderQrPngBase64", () => { + beforeEach(() => { + renderPngBase64.mockClear(); + }); + + it("delegates PNG rendering to qrcode-tui", async () => { + await expect(renderQrPngBase64("openclaw", { scale: 8, marginModules: 2 })).resolves.toBe( + "mocked-base64", + ); + expect(renderPngBase64).toHaveBeenCalledWith("openclaw", { + margin: 2, + scale: 8, + }); + }); + + it("uses the default PNG rendering options", async () => { + await renderQrPngBase64("openclaw"); + expect(renderPngBase64).toHaveBeenCalledWith("openclaw", { + margin: 4, + scale: 6, + }); + }); + + it("floors finite PNG rendering options before delegating", async () => { + await renderQrPngBase64("openclaw", { scale: 8.9, marginModules: 2.9 }); + expect(renderPngBase64).toHaveBeenCalledWith("openclaw", { + margin: 2, + scale: 8, + }); + }); + + it.each([ + ["scale", 0, 4, "scale must be between 1 and 12."], + ["scale", 13, 4, "scale must be between 1 and 12."], + ["scale", Number.NaN, 4, "scale must be a finite number."], + ["marginModules", 6, -1, "marginModules must be between 0 and 16."], + ["marginModules", 6, 17, "marginModules must be between 0 and 16."], + ["marginModules", 6, Number.POSITIVE_INFINITY, "marginModules must be a finite number."], + ])("rejects invalid %s values", async (_name, scale, marginModules, message) => { + await expect(renderQrPngBase64("openclaw", { scale, marginModules })).rejects.toThrow(message); + expect(renderPngBase64).not.toHaveBeenCalled(); + }); +}); diff --git a/src/media/qr-image.ts b/src/media/qr-image.ts index 942cfd4b03c..b954648f6eb 100644 --- a/src/media/qr-image.ts +++ b/src/media/qr-image.ts @@ -1,68 +1,53 @@ -import { encodePngRgba, fillPixel } from "./png-encode.ts"; +import { loadQrCodeTuiRuntime } from "./qr-runtime.ts"; -type QRCodeConstructor = new ( - typeNumber: number, - errorCorrectLevel: unknown, -) => { - addData: (data: string) => void; - make: () => void; - getModuleCount: () => number; - isDark: (row: number, col: number) => boolean; -}; +const DEFAULT_QR_PNG_SCALE = 6; +const DEFAULT_QR_PNG_MARGIN_MODULES = 4; +const MIN_QR_PNG_SCALE = 1; +const MAX_QR_PNG_SCALE = 12; +const MIN_QR_PNG_MARGIN_MODULES = 0; +const MAX_QR_PNG_MARGIN_MODULES = 16; -let qrCodeRuntimePromise: Promise<{ - QRCode: QRCodeConstructor; - QRErrorCorrectLevel: Record; -}> | null = null; - -async function loadQrCodeRuntime() { - if (!qrCodeRuntimePromise) { - qrCodeRuntimePromise = Promise.all([ - import("qrcode-terminal/vendor/QRCode/index.js"), - import("qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"), - ]).then(([qrCodeModule, errorCorrectLevelModule]) => ({ - QRCode: qrCodeModule.default as QRCodeConstructor, - QRErrorCorrectLevel: errorCorrectLevelModule.default, - })); +function resolveQrPngIntegerOption(params: { + name: string; + value: number | undefined; + defaultValue: number; + min: number; + max: number; +}): number { + if (params.value === undefined) { + return params.defaultValue; } - return await qrCodeRuntimePromise; -} - -async function createQrMatrix(input: string) { - const { QRCode, QRErrorCorrectLevel } = await loadQrCodeRuntime(); - const qr = new QRCode(-1, QRErrorCorrectLevel.L); - qr.addData(input); - qr.make(); - return qr; + if (!Number.isFinite(params.value)) { + throw new RangeError(`${params.name} must be a finite number.`); + } + const value = Math.floor(params.value); + if (value < params.min || value > params.max) { + throw new RangeError(`${params.name} must be between ${params.min} and ${params.max}.`); + } + return value; } export async function renderQrPngBase64( input: string, opts: { scale?: number; marginModules?: number } = {}, ): Promise { - const { scale = 6, marginModules = 4 } = opts; - const qr = await createQrMatrix(input); - const modules = qr.getModuleCount(); - const size = (modules + marginModules * 2) * scale; - - const buf = Buffer.alloc(size * size * 4, 255); - for (let row = 0; row < modules; row += 1) { - for (let col = 0; col < modules; col += 1) { - if (!qr.isDark(row, col)) { - continue; - } - const startX = (col + marginModules) * scale; - const startY = (row + marginModules) * scale; - for (let y = 0; y < scale; y += 1) { - const pixelY = startY + y; - for (let x = 0; x < scale; x += 1) { - const pixelX = startX + x; - fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); - } - } - } - } - - const png = encodePngRgba(buf, size, size); - return png.toString("base64"); + const scale = resolveQrPngIntegerOption({ + name: "scale", + value: opts.scale, + defaultValue: DEFAULT_QR_PNG_SCALE, + min: MIN_QR_PNG_SCALE, + max: MAX_QR_PNG_SCALE, + }); + const marginModules = resolveQrPngIntegerOption({ + name: "marginModules", + value: opts.marginModules, + defaultValue: DEFAULT_QR_PNG_MARGIN_MODULES, + min: MIN_QR_PNG_MARGIN_MODULES, + max: MAX_QR_PNG_MARGIN_MODULES, + }); + const { renderPngBase64 } = await loadQrCodeTuiRuntime(); + return await renderPngBase64(input, { + margin: marginModules, + scale, + }); } diff --git a/src/media/qr-runtime.ts b/src/media/qr-runtime.ts new file mode 100644 index 00000000000..dcedf92f9cf --- /dev/null +++ b/src/media/qr-runtime.ts @@ -0,0 +1,8 @@ +let qrCodeTuiRuntimePromise: Promise | null = null; + +export async function loadQrCodeTuiRuntime() { + if (!qrCodeTuiRuntimePromise) { + qrCodeTuiRuntimePromise = import("@vincentkoc/qrcode-tui"); + } + return await qrCodeTuiRuntimePromise; +} diff --git a/src/media/qr-terminal.ts b/src/media/qr-terminal.ts new file mode 100644 index 00000000000..3c214914508 --- /dev/null +++ b/src/media/qr-terminal.ts @@ -0,0 +1,9 @@ +import { loadQrCodeTuiRuntime } from "./qr-runtime.ts"; + +export async function renderQrTerminal( + input: string, + opts: { small?: boolean } = {}, +): Promise { + const { renderTerminal } = await loadQrCodeTuiRuntime(); + return await renderTerminal(input, { small: opts.small ?? true }); +} diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index a7b7e85f3d2..decc72efd25 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -15,6 +15,7 @@ export * from "../media/mime.js"; export * from "../media/outbound-attachment.js"; export * from "../media/png-encode.ts"; export * from "../media/qr-image.ts"; +export * from "../media/qr-terminal.ts"; export * from "../media/read-response-with-limit.js"; export * from "../media/store.js"; export * from "../media/temp-files.js"; diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index 406095b8982..112963c3183 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -18,7 +18,7 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ { pluginId: "feishu", pluginLocalRuntimeDeps: ["@larksuiteoapi/node-sdk"], - mirroredRootRuntimeDeps: ["typebox", "qrcode-terminal"], + mirroredRootRuntimeDeps: ["typebox"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "google", pluginLocalRuntimeDeps: ["@google/genai"] }, @@ -105,7 +105,6 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ { pluginId: "whatsapp", pluginLocalRuntimeDeps: ["@whiskeysockets/baileys", "jimp"], - mirroredRootRuntimeDeps: ["qrcode-terminal"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "zalo", minHostVersionBaseline: "2026.3.22" }, diff --git a/src/types/qrcode-terminal.d.ts b/src/types/qrcode-terminal.d.ts deleted file mode 100644 index ebcdfbc2682..00000000000 --- a/src/types/qrcode-terminal.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -declare module "qrcode-terminal" { - type GenerateOptions = { - small?: boolean; - }; - - type QrCodeTerminal = { - generate: (input: string, options?: GenerateOptions, cb?: (output: string) => void) => void; - }; - - const qrcode: QrCodeTerminal; - export default qrcode; -} - -declare module "qrcode-terminal/vendor/QRCode/index.js" { - const QRCode: unknown; - export default QRCode; -} - -declare module "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js" { - const QRErrorCorrectLevel: Record; - export default QRErrorCorrectLevel; -} diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index a38e5654e1a..87f42d5d7f5 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -431,6 +431,29 @@ describe("runSetupWizard", () => { expect(runTui).not.toHaveBeenCalled(); }); + it("fails fast if the auth choice prompt returns nothing", async () => { + promptAuthChoiceGrouped.mockImplementationOnce(async () => undefined as never); + const prompter = buildWizardPrompter(); + const runtime = createRuntime(); + + await expect( + runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ), + ).rejects.toThrow("auth choice is required"); + }); + async function runTuiHatchTest(params: { writeBootstrapFile: boolean; expectedMessage: string | undefined;