diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index a30f0e2cc..a2ca3aab2 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas import { tryConnectToDaemon } from "../../utils/client.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; +import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { home?: string; @@ -97,6 +98,11 @@ function outputPairingResult( return; } - const qrBlock = pairing.qr ? `${pairing.qr}\n` : ""; - process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`); + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), + ); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 2600bdb29..22e10cf4a 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -18,6 +18,7 @@ import { type DaemonStartOptions, } from "./daemon/local-daemon.js"; import { tryConnectToDaemon } from "../utils/client.js"; +import { formatPairingInstructions } from "../output/pairing.js"; interface OnboardOptions extends DaemonStartOptions { timeout?: string; @@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise { return; } - renderNote( - pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.", - "Scan to pair", + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), ); - renderNote(pairing.url, "Pairing link"); printNextSteps(pairing.url, paseoHome, richUi); if (richUi) { outro("Paseo is ready!"); diff --git a/packages/cli/src/output/pairing.test.ts b/packages/cli/src/output/pairing.test.ts new file mode 100644 index 000000000..00ed6b0a8 --- /dev/null +++ b/packages/cli/src/output/pairing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatPairingInstructions } from "./pairing.js"; + +const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m"; +const URL = "https://app.paseo.sh/#offer=pairing-offer"; + +describe("formatPairingInstructions", () => { + it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 }); + + expect(output).toContain(QR); + expect(output.split("\n")).toContain(URL); + }); + + it("does not print a QR that would reach the terminal edge", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 }); + + expect(output).not.toContain(QR); + expect(output).toContain("Resize the terminal to at least 7 columns"); + expect(output.split("\n")).toContain(URL); + }); + + it("does not risk printing a QR when terminal width is unknown", () => { + const output = formatPairingInstructions({ qr: QR, url: URL }); + + expect(output).not.toContain(QR); + expect(output).toContain("terminal width could not be detected"); + expect(output.split("\n")).toContain(URL); + }); +}); diff --git a/packages/cli/src/output/pairing.ts b/packages/cli/src/output/pairing.ts new file mode 100644 index 000000000..6d0da5bf6 --- /dev/null +++ b/packages/cli/src/output/pairing.ts @@ -0,0 +1,37 @@ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); + +interface PairingInstructions { + url: string; + qr: string | null; + columns?: number; +} + +function visibleWidth(value: string): number { + return Math.max( + ...value + .replace(ANSI_PATTERN, "") + .split("\n") + .map((line) => line.length), + ); +} + +function formatQr(qr: string | null, columns: number | undefined): string { + if (!qr) { + return "QR code is unavailable. Use the pairing link below."; + } + + if (columns === undefined) { + return "QR code not shown because terminal width could not be detected."; + } + + const width = visibleWidth(qr); + if (columns <= width) { + return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`; + } + + return qr; +} + +export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string { + return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`; +} diff --git a/packages/server/src/server/pairing-qr.test.ts b/packages/server/src/server/pairing-qr.test.ts new file mode 100644 index 000000000..27f317c6e --- /dev/null +++ b/packages/server/src/server/pairing-qr.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import * as QRCode from "qrcode"; +import { renderPairingQr } from "./pairing-qr.js"; + +const ESCAPE = String.fromCharCode(0x1b); +const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g"); + +describe("renderPairingQr", () => { + it("renders a theme-independent QR code with a four-module quiet zone", async () => { + const url = "https://app.paseo.sh/#offer=test-pairing-offer"; + const qr = await renderPairingQr(url); + const styledLines = qr.split("\n"); + const visibleLines = qr.replace(ANSI_PATTERN, "").split("\n"); + const moduleCount = QRCode.create(url).modules.size; + + expect( + styledLines.every( + (line) => line.startsWith(`${ESCAPE}[47m${ESCAPE}[30m`) && line.endsWith(`${ESCAPE}[0m`), + ), + ).toBe(true); + expect(visibleLines.every((line) => line.length === moduleCount + 8)).toBe(true); + expect(visibleLines.slice(0, 2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.slice(-2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.every((line) => line.startsWith(" ") && line.endsWith(" "))).toBe( + true, + ); + }); +}); diff --git a/packages/server/src/server/pairing-qr.ts b/packages/server/src/server/pairing-qr.ts index 8b8f31f44..7d9d13167 100644 --- a/packages/server/src/server/pairing-qr.ts +++ b/packages/server/src/server/pairing-qr.ts @@ -1,50 +1,18 @@ import * as QRCode from "qrcode"; -import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode"; -import type { Logger } from "pino"; +import type { QRCodeToStringOptionsOther } from "qrcode"; -function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; - if (["0", "false", "no", "n", "off"].includes(normalized)) return false; - return undefined; -} - -function shouldPrintPairingQr(): boolean { - const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR); - if (env !== undefined) return env; - return process.stdout.isTTY ?? false; -} +const BLACK_ON_WHITE = "\u001b[47m\u001b[30m"; +const RESET_COLORS = "\u001b[0m"; export async function renderPairingQr(url: string): Promise { - const terminalOptions: QRCodeToStringOptionsTerminal = { - type: "terminal", - small: true, - }; - const utf8Options: QRCodeToStringOptionsOther = { type: "utf8", + margin: 4, }; - try { - return await QRCode.toString(url, terminalOptions); - } catch { - return await QRCode.toString(url, utf8Options); - } -} - -export async function printPairingQrIfEnabled(args: { - url: string; - logger?: Logger; -}): Promise { - if (!shouldPrintPairingQr()) return; - - const qr = await renderPairingQr(args.url); - const out = `\nScan to pair:\n${qr}\n${args.url}\n`; - - try { - process.stdout.write(out); - } catch (error) { - args.logger?.debug({ error }, "Failed to print pairing QR"); - } + const qr = await QRCode.toString(url, utf8Options); + return qr + .split("\n") + .map((line) => `${BLACK_ON_WHITE}${line}${RESET_COLORS}`) + .join("\n"); }