@ait-co/devtools 0.1.58 → 0.1.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/deeplink-BONXxWEO.cjs +44 -0
  2. package/dist/deeplink-BONXxWEO.cjs.map +1 -0
  3. package/dist/deeplink-CCGiyoHq.cjs +44 -0
  4. package/dist/deeplink-CCGiyoHq.cjs.map +1 -0
  5. package/dist/deeplink-CaO6hZVG.js +44 -0
  6. package/dist/deeplink-CaO6hZVG.js.map +1 -0
  7. package/dist/deeplink-Cqli4qzm.js +44 -0
  8. package/dist/deeplink-Cqli4qzm.js.map +1 -0
  9. package/dist/devtools-opener-BbUXBzgA.js +65 -0
  10. package/dist/devtools-opener-BbUXBzgA.js.map +1 -0
  11. package/dist/devtools-opener-Bp671YXu.cjs +62 -0
  12. package/dist/devtools-opener-Bp671YXu.cjs.map +1 -0
  13. package/dist/devtools-opener-D84kZFtR.js +65 -0
  14. package/dist/devtools-opener-D84kZFtR.js.map +1 -0
  15. package/dist/devtools-opener-h6A-UjzC.cjs +62 -0
  16. package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -0
  17. package/dist/mcp/cli.js +862 -403
  18. package/dist/mcp/cli.js.map +1 -1
  19. package/dist/mcp/server.js +5 -1
  20. package/dist/mcp/server.js.map +1 -1
  21. package/dist/panel/index.d.ts +15 -9
  22. package/dist/panel/index.d.ts.map +1 -1
  23. package/dist/panel/index.js +27705 -1965
  24. package/dist/panel/index.js.map +1 -1
  25. package/dist/qr-http-server-Byk0Yjk_.cjs +901 -0
  26. package/dist/qr-http-server-Byk0Yjk_.cjs.map +1 -0
  27. package/dist/qr-http-server-D_Aj5Vq6.cjs +901 -0
  28. package/dist/qr-http-server-D_Aj5Vq6.cjs.map +1 -0
  29. package/dist/qr-http-server-N4mX8GaC.js +901 -0
  30. package/dist/qr-http-server-N4mX8GaC.js.map +1 -0
  31. package/dist/qr-http-server-kYvmlXlg.js +901 -0
  32. package/dist/qr-http-server-kYvmlXlg.js.map +1 -0
  33. package/dist/relay-secret-store-5A7_7zOp.js +111 -0
  34. package/dist/relay-secret-store-5A7_7zOp.js.map +1 -0
  35. package/dist/{relay-secret-store-DqyUoeXy.js → relay-secret-store-C4QQN5NA.js} +3 -3
  36. package/dist/{relay-secret-store-DqyUoeXy.js.map → relay-secret-store-C4QQN5NA.js.map} +1 -1
  37. package/dist/{relay-secret-store-DnTNl-9z.cjs → relay-secret-store-CLkF8Pa0.cjs} +3 -2
  38. package/dist/{relay-secret-store-DnTNl-9z.cjs.map → relay-secret-store-CLkF8Pa0.cjs.map} +1 -1
  39. package/dist/relay-url-store-COG2dSql.cjs +113 -0
  40. package/dist/relay-url-store-COG2dSql.cjs.map +1 -0
  41. package/dist/relay-url-store-WKfo0VQV.js +112 -0
  42. package/dist/relay-url-store-WKfo0VQV.js.map +1 -0
  43. package/dist/relay-url-store-qaoe0zOD.js +118 -0
  44. package/dist/relay-url-store-qaoe0zOD.js.map +1 -0
  45. package/dist/totp-86i_CNqh.js +3 -0
  46. package/dist/{totp-D0a8VwoR.js → totp-BIrJHsQn.js} +1 -1
  47. package/dist/{totp-D0a8VwoR.js.map → totp-BIrJHsQn.js.map} +1 -1
  48. package/dist/{totp-BkP5yU2K.js → totp-BjtKFt88.js} +2 -2
  49. package/dist/{totp-BkP5yU2K.js.map → totp-BjtKFt88.js.map} +1 -1
  50. package/dist/totp-BxtxuEt4.js +64 -0
  51. package/dist/totp-BxtxuEt4.js.map +1 -0
  52. package/dist/totp-D9rndqg_.cjs +64 -0
  53. package/dist/totp-D9rndqg_.cjs.map +1 -0
  54. package/dist/{totp-DLgGbySX.cjs → totp-DA8vjAi7.cjs} +2 -1
  55. package/dist/{totp-DLgGbySX.cjs.map → totp-DA8vjAi7.cjs.map} +1 -1
  56. package/dist/{tunnel-nKYPtc-g.cjs → tunnel-GieyWa22.cjs} +114 -3
  57. package/dist/tunnel-GieyWa22.cjs.map +1 -0
  58. package/dist/{tunnel-CI61NvPI.js → tunnel-JuZ5_Pci.js} +114 -4
  59. package/dist/tunnel-JuZ5_Pci.js.map +1 -0
  60. package/dist/unplugin/index.cjs +116 -4
  61. package/dist/unplugin/index.cjs.map +1 -1
  62. package/dist/unplugin/index.d.cts +20 -1
  63. package/dist/unplugin/index.d.cts.map +1 -1
  64. package/dist/unplugin/index.d.ts +20 -1
  65. package/dist/unplugin/index.d.ts.map +1 -1
  66. package/dist/unplugin/index.js +116 -5
  67. package/dist/unplugin/index.js.map +1 -1
  68. package/dist/unplugin/tunnel.cjs +114 -2
  69. package/dist/unplugin/tunnel.cjs.map +1 -1
  70. package/dist/unplugin/tunnel.d.cts +62 -1
  71. package/dist/unplugin/tunnel.d.cts.map +1 -1
  72. package/dist/unplugin/tunnel.d.ts +62 -1
  73. package/dist/unplugin/tunnel.d.ts.map +1 -1
  74. package/dist/unplugin/tunnel.js +113 -3
  75. package/dist/unplugin/tunnel.js.map +1 -1
  76. package/package.json +11 -3
  77. package/dist/totp-CQFmgOhM.js +0 -3
  78. package/dist/tunnel-CI61NvPI.js.map +0 -1
  79. package/dist/tunnel-nKYPtc-g.cjs.map +0 -1
@@ -50,12 +50,73 @@ declare function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string):
50
50
  * other than the injected `log` sink and `qrcode-terminal` — unit-tested.
51
51
  */
52
52
  declare function printTunnelBanner(url: string, opts?: PrintTunnelBannerOptions): Promise<void>;
53
+ /** Handle returned by {@link startTunnelDashboard}. */
54
+ interface TunnelDashboard {
55
+ /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */
56
+ url: string;
57
+ /** Tear down the local HTTP server. Idempotent via the underlying server. */
58
+ close: () => Promise<void>;
59
+ }
60
+ interface StartTunnelDashboardOptions {
61
+ /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */
62
+ tunnelUrl: string;
63
+ /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */
64
+ relayWssUrl: string;
65
+ /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */
66
+ qr?: boolean;
67
+ /**
68
+ * Override the GUI/opt-out gate (testing only). When omitted the real
69
+ * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.
70
+ */
71
+ shouldOpen?: () => boolean;
72
+ /** Sink for the one-line "opened in browser" note (default: `console.log`). Injected for testing. */
73
+ log?: (msg: string) => void;
74
+ }
75
+ /**
76
+ * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is
77
+ * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps
78
+ * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the
79
+ * browser. headless / opt-out falls back to the terminal ASCII QR (printed
80
+ * separately by {@link printTunnelBanner}).
81
+ *
82
+ * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP
83
+ * server, the opener) is reached only through dynamic `import()` here, inside
84
+ * the already-lazy `tunnel.js` chunk — nothing is added to the common build
85
+ * graph or the MCP-only install graph.
86
+ *
87
+ * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH
88
+ * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds
89
+ * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on
90
+ * each SSE push / page reload from this closure, the code a phone scans is always
91
+ * within its 30 s window — no stale code is baked into static HTML.
92
+ *
93
+ * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`
94
+ * value/path are NEVER written to stdout/stderr/logs here. They live only inside
95
+ * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).
96
+ * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).
97
+ *
98
+ * @returns the dashboard handle when it started (caller wires `close()` into the
99
+ * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,
100
+ * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.
101
+ */
102
+ declare function startTunnelDashboard(opts: StartTunnelDashboardOptions): Promise<TunnelDashboard | undefined>;
53
103
  interface QuickTunnel {
54
104
  /** The public `https://*.trycloudflare.com` URL. */
55
105
  url: string;
56
106
  /** Stop the underlying `cloudflared` process. Idempotent. */
57
107
  stop: () => void;
58
108
  }
109
+ /**
110
+ * Sanitize cloudflared stderr output for error diagnostics (#421).
111
+ *
112
+ * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs
113
+ * that carry those hostnames so tunnel host values never appear in error
114
+ * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.
115
+ *
116
+ * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only
117
+ * placeholder text is emitted.
118
+ */
119
+ declare function sanitizeCloudflaredOutput(line: string): string;
59
120
  /**
60
121
  * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
61
122
  * and resolve once the public URL is known. Downloads the `cloudflared` binary
@@ -64,5 +125,5 @@ interface QuickTunnel {
64
125
  */
65
126
  declare function startQuickTunnel(port: number): Promise<QuickTunnel>;
66
127
  //#endregion
67
- export { PrintTunnelBannerOptions, QuickTunnel, buildLauncherDeepLink, parseTrycloudflareUrl, printTunnelBanner, startQuickTunnel };
128
+ export { PrintTunnelBannerOptions, QuickTunnel, StartTunnelDashboardOptions, TunnelDashboard, buildLauncherDeepLink, parseTrycloudflareUrl, printTunnelBanner, sanitizeCloudflaredOutput, startQuickTunnel, startTunnelDashboard };
68
129
  //# sourceMappingURL=tunnel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","names":[],"sources":["../../src/unplugin/tunnel.ts"],"mappings":";;AAwBA;;;;;AAKA;;;;;;;;;;iBALgB,qBAAA,CAAsB,IAAA;AAAA,UAKrB,wBAAA;;EAEf,EAAA;EA2B2E;EAzB3E,GAAA,IAAO,GAAA;EAqC8B;;;;;;EA9BrC,WAAA;AAAA;;AAsEF;;;;;AAeA;;;;;;;iBAnEgB,qBAAA,CAAsB,SAAA,UAAmB,WAAA;;;;;;;iBAYnC,iBAAA,CACpB,GAAA,UACA,IAAA,GAAM,wBAAA,GACL,OAAA;AAAA,UAqCc,WAAA;;EAEf,GAAA;;EAEA,IAAA;AAAA;;;;;;;iBAWoB,gBAAA,CAAiB,IAAA,WAAe,OAAA,CAAQ,WAAA"}
1
+ {"version":3,"file":"tunnel.d.ts","names":[],"sources":["../../src/unplugin/tunnel.ts"],"mappings":";;AAwBA;;;;;AAKA;;;;;;;;;;iBALgB,qBAAA,CAAsB,IAAA;AAAA,UAKrB,wBAAA;;EAEf,EAAA;EA2B2E;EAzB3E,GAAA,IAAO,GAAA;EAqC8B;;;;;;EA9BrC,WAAA;AAAA;;AA2FF;;;;;;;;;AAOA;;;iBAhFgB,qBAAA,CAAsB,SAAA,UAAmB,WAAA;;;;;;;iBAYnC,iBAAA,CACpB,GAAA,UACA,IAAA,GAAM,wBAAA,GACL,OAAA;;UA0Dc,eAAA;EAkDyB;EAhDxC,GAAA;EAiDM;EA/CN,KAAA,QAAa,OAAA;AAAA;AAAA,UAGE,2BAAA;EA6CP;EA3CR,SAAA;EA0CA;EAxCA,WAAA;EAyCS;EAvCT,EAAA;EAuCwB;AA8D1B;;;EAhGE,UAAA;EAoGI;EAlGJ,GAAA,IAAO,GAAA;AAAA;;;;AAiIT;;;;;;;;;;;;;;;;;;;;;;;;iBAnGsB,oBAAA,CACpB,IAAA,EAAM,2BAAA,GACL,OAAA,CAAQ,eAAA;AAAA,UA8DM,WAAA;;EAEf,GAAA;;EAEA,IAAA;AAAA;;;;;;;;;;;iBAac,yBAAA,CAA0B,IAAA;;;;;;;iBAkBpB,gBAAA,CAAiB,IAAA,WAAe,OAAA,CAAQ,WAAA"}
@@ -76,6 +76,101 @@ async function printTunnelBanner(url, opts = {}) {
76
76
  });
77
77
  }
78
78
  }
79
+ /**
80
+ * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in
81
+ * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path
82
+ * does not statically pull the heavy MCP `tools.ts` module graph into the lazy
83
+ * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.
84
+ *
85
+ * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).
86
+ * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.
87
+ * - CI (`CI=true`/`CI=1`) → no.
88
+ */
89
+ function canOpenBrowser() {
90
+ if (process.env.CI === "true" || process.env.CI === "1") return false;
91
+ const platform = process.platform;
92
+ if (platform === "darwin" || platform === "win32") return true;
93
+ if (platform === "linux") return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);
94
+ return false;
95
+ }
96
+ /**
97
+ * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is
98
+ * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps
99
+ * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the
100
+ * browser. headless / opt-out falls back to the terminal ASCII QR (printed
101
+ * separately by {@link printTunnelBanner}).
102
+ *
103
+ * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP
104
+ * server, the opener) is reached only through dynamic `import()` here, inside
105
+ * the already-lazy `tunnel.js` chunk — nothing is added to the common build
106
+ * graph or the MCP-only install graph.
107
+ *
108
+ * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH
109
+ * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds
110
+ * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on
111
+ * each SSE push / page reload from this closure, the code a phone scans is always
112
+ * within its 30 s window — no stale code is baked into static HTML.
113
+ *
114
+ * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`
115
+ * value/path are NEVER written to stdout/stderr/logs here. They live only inside
116
+ * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).
117
+ * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).
118
+ *
119
+ * @returns the dashboard handle when it started (caller wires `close()` into the
120
+ * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,
121
+ * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.
122
+ */
123
+ async function startTunnelDashboard(opts) {
124
+ const log = opts.log ?? ((m) => console.log(m));
125
+ if (!opts.relayWssUrl) return void 0;
126
+ if (opts.qr === false) return void 0;
127
+ const { isAutoDevtoolsDisabled } = await import("../devtools-opener-D84kZFtR.js");
128
+ if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
129
+ const { startQrHttpServer } = await import("../qr-http-server-N4mX8GaC.js");
130
+ const { buildLauncherAttachUrl } = await import("../deeplink-Cqli4qzm.js");
131
+ const { generateTotp } = await import("../totp-BxtxuEt4.js");
132
+ const getDashboardState = () => {
133
+ const secret = process.env.AIT_DEBUG_TOTP_SECRET;
134
+ const totpCode = secret ? generateTotp(secret, Date.now()) : void 0;
135
+ const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode);
136
+ return {
137
+ tunnel: {
138
+ up: true,
139
+ wssUrl: opts.relayWssUrl
140
+ },
141
+ pages: null,
142
+ attachUrl
143
+ };
144
+ };
145
+ let server;
146
+ try {
147
+ server = await startQrHttpServer(getDashboardState);
148
+ } catch {
149
+ return;
150
+ }
151
+ const dashboardUrl = `http://127.0.0.1:${server.port}`;
152
+ const { openUrlInBrowser } = await import("../devtools-opener-D84kZFtR.js");
153
+ log(openUrlInBrowser(dashboardUrl) ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}` : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`);
154
+ return {
155
+ url: dashboardUrl,
156
+ close: () => server.close()
157
+ };
158
+ }
159
+ /**
160
+ * Sanitize cloudflared stderr output for error diagnostics (#421).
161
+ *
162
+ * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs
163
+ * that carry those hostnames so tunnel host values never appear in error
164
+ * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.
165
+ *
166
+ * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only
167
+ * placeholder text is emitted.
168
+ */
169
+ function sanitizeCloudflaredOutput(line) {
170
+ let s = line.replace(/(?:https?|wss?):\/\/[a-z0-9-]+\.trycloudflare\.com(?:\/[^\s]*)*/gi, (m) => m.replace(/[a-z0-9-]+\.trycloudflare\.com/i, "<HOST>.trycloudflare.com"));
171
+ s = s.replace(/[a-z0-9-]+\.trycloudflare\.com/gi, "<HOST>.trycloudflare.com");
172
+ return s;
173
+ }
79
174
  const URL_TIMEOUT_MS = 2e4;
80
175
  /**
81
176
  * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
@@ -99,10 +194,20 @@ async function startQuickTunnel(port) {
99
194
  } catch {}
100
195
  };
101
196
  return new Promise((resolve, reject) => {
197
+ const stderrLines = [];
198
+ /**
199
+ * Format the last `n` sanitized stderr lines as a diagnostic appendix.
200
+ * Returns an empty string when no lines have been collected.
201
+ */
202
+ const stderrTail = (n = 15) => {
203
+ if (stderrLines.length === 0) return "";
204
+ const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join("");
205
+ return `\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\n${tail}`;
206
+ };
102
207
  const timer = setTimeout(() => {
103
208
  cleanup();
104
209
  stop();
105
- reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually.`));
210
+ reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually.${stderrTail()}`));
106
211
  }, URL_TIMEOUT_MS);
107
212
  const onUrl = (line) => {
108
213
  const found = parseTrycloudflareUrl(line);
@@ -114,13 +219,18 @@ async function startQuickTunnel(port) {
114
219
  stop
115
220
  });
116
221
  };
222
+ const pushStderr = (line) => {
223
+ stderrLines.push(line);
224
+ };
117
225
  const cleanup = () => {
118
226
  tunnel.off("stdout", onUrl);
119
227
  tunnel.off("stderr", onUrl);
228
+ tunnel.off("stderr", pushStderr);
120
229
  };
121
230
  tunnel.once("url", onUrl);
122
231
  tunnel.on("stdout", onUrl);
123
232
  tunnel.on("stderr", onUrl);
233
+ tunnel.on("stderr", pushStderr);
124
234
  tunnel.once("error", (err) => {
125
235
  clearTimeout(timer);
126
236
  cleanup();
@@ -131,11 +241,11 @@ async function startQuickTunnel(port) {
131
241
  if (stopped) return;
132
242
  clearTimeout(timer);
133
243
  cleanup();
134
- reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.`));
244
+ reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.${stderrTail()}`));
135
245
  });
136
246
  });
137
247
  }
138
248
  //#endregion
139
- export { buildLauncherDeepLink, parseTrycloudflareUrl, printTunnelBanner, startQuickTunnel };
249
+ export { buildLauncherDeepLink, parseTrycloudflareUrl, printTunnelBanner, sanitizeCloudflaredOutput, startQuickTunnel, startTunnelDashboard };
140
250
 
141
251
  //# sourceMappingURL=tunnel.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.js","names":[],"sources":["../../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,CAAC,WAAW,IAAI,EAAE;AACpB,QAAM,MAAM,QAAQ,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAG/B,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;;AAK7B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}
1
+ {"version":3,"file":"tunnel.js","names":[],"sources":["../../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode);\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n return { tunnel: { up: true, wssUrl: opts.relayWssUrl }, pages: null, attachUrl };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => server.close(),\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;;;;;;;;;;;AAcN,SAAS,iBAA0B;AACjC,KAAI,QAAQ,IAAI,OAAO,UAAU,QAAQ,IAAI,OAAO,IAAK,QAAO;CAChE,MAAM,WAAW,QAAQ;AACzB,KAAI,aAAa,YAAY,aAAa,QAAS,QAAO;AAC1D,KAAI,aAAa,QACf,QAAO,QAAQ,QAAQ,IAAI,WAAW,QAAQ,IAAI,gBAAgB;AAEpE,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDT,eAAsB,qBACpB,MACsC;CACtC,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;AAGtD,KAAI,CAAC,KAAK,YAAa,QAAO,KAAA;AAE9B,KAAI,KAAK,OAAO,MAAO,QAAO,KAAA;CAI9B,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAEhD,KAAI,EADa,KAAK,qBAAqB,CAAC,wBAAwB,IAAI,gBAAgB,IACzE,CAAE,QAAO,KAAA;CAExB,MAAM,EAAE,sBAAsB,MAAM,OAAO;CAC3C,MAAM,EAAE,2BAA2B,MAAM,OAAO;CAChD,MAAM,EAAE,iBAAiB,MAAM,OAAO;CAQtC,MAAM,0BAA0B;EAC9B,MAAM,SAAS,QAAQ,IAAI;EAC3B,MAAM,WAAW,SAAS,aAAa,QAAQ,KAAK,KAAK,CAAC,GAAG,KAAA;EAC7D,MAAM,YAAY,uBAAuB,KAAK,WAAW,KAAK,aAAa,SAAS;AAKpF,SAAO;GAAE,QAAQ;IAAE,IAAI;IAAM,QAAQ,KAAK;IAAa;GAAE,OAAO;GAAM;GAAW;;CAGnF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,kBAAkB,kBAAkB;SAC7C;AAGN;;CAGF,MAAM,eAAe,oBAAoB,OAAO;CAEhD,MAAM,EAAE,qBAAqB,MAAM,OAAO;AAI1C,KAHe,iBAAiB,aAAa,GAKvC,+CAA+C,iBAC/C,gDAAgD,eACrD;AAED,QAAO;EACL,KAAK;EACL,aAAa,OAAO,OAAO;EAC5B;;;;;;;;;;;;AAoBH,SAAgB,0BAA0B,MAAsB;CAE9D,IAAI,IAAI,KAAK,QAAQ,sEAAsE,MACzF,EAAE,QAAQ,mCAAmC,2BAA2B,CACzE;AAED,KAAI,EAAE,QAAQ,oCAAoC,2BAA2B;AAC7E,QAAO;;AAGT,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,CAAC,WAAW,IAAI,EAAE;AACpB,QAAM,MAAM,QAAQ,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EAGnD,MAAM,cAAwB,EAAE;;;;;EAMhC,MAAM,cAAc,IAAI,OAAe;AACrC,OAAI,YAAY,WAAW,EAAG,QAAO;GACrC,MAAM,OAAO,YAAY,MAAM,CAAC,EAAE,CAAC,IAAI,0BAA0B,CAAC,KAAK,GAAG;AAC1E,UAAO,yBAAyB,KAAK,IAAI,GAAG,YAAY,OAAO,CAAC,OAAO;;EAGzE,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAAc,YAAY,GACxH,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAK/B,MAAM,cAAc,SAAiB;AACnC,eAAY,KAAK,KAAK;;EAGxB,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,WAAW;;AAKlC,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAE1B,SAAO,GAAG,UAAU,WAAW;AAC/B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAAkC,YAAY,GAC7G,CACF;IACD;GACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ait-co/devtools",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "Development tools for Apps in Toss mini-apps — mock SDK, floating devtools panel, and universal bundler plugin",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,13 +69,18 @@
69
69
  "@changesets/cli": "^2.31.0",
70
70
  "@khmyznikov/pwa-install": "^0.6.3",
71
71
  "@playwright/test": "^1.59.1",
72
+ "@testing-library/dom": "^10.4.1",
73
+ "@testing-library/react": "^16.3.0",
72
74
  "@types/qrcode": "^1.5.6",
73
75
  "@types/qrcode-terminal": "^0.12.2",
74
76
  "@types/react": "^19.2.14",
77
+ "@types/react-dom": "^19.2.3",
75
78
  "@types/ws": "^8.18.0",
79
+ "@vitejs/plugin-react": "^5.1.0",
76
80
  "jsdom": "^29.1.1",
77
81
  "qr-scanner": "^1.4.2",
78
- "react": "^19.2.5",
82
+ "react": "^19.2.6",
83
+ "react-dom": "^19.2.6",
79
84
  "satori": "^0.26.0",
80
85
  "sharp": "^0.34.5",
81
86
  "tsdown": "^0.21.7",
@@ -110,7 +115,10 @@
110
115
  "url": "https://github.com/apps-in-toss-community/devtools/issues"
111
116
  },
112
117
  "scripts": {
113
- "build": "tsdown",
118
+ "build:dashboard-html": "tsx --tsconfig scripts/tsconfig.json scripts/build-dashboard-html.ts",
119
+ "build": "pnpm build:dashboard-html && tsdown",
120
+ "check:mcp-react-free": "bash scripts/check-mcp-react-free.sh",
121
+ "check:dashboard-html-fresh": "pnpm build:dashboard-html && git diff --exit-code src/mcp/dashboard.generated.ts",
114
122
  "dev": "tsdown --watch",
115
123
  "typecheck": "tsc --noEmit && tsc --noEmit -p e2e/fixture/tsconfig.json && tsc --noEmit -p scripts/tsconfig.json",
116
124
  "test": "vitest run",
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- import { a as isValidRelayAuthSecret } from "./totp-D0a8VwoR.js";
3
- export { isValidRelayAuthSecret };
@@ -1 +0,0 @@
1
- {"version":3,"file":"tunnel-CI61NvPI.js","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,CAAC,WAAW,IAAI,EAAE;AACpB,QAAM,MAAM,QAAQ,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAG/B,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;;AAK7B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"tunnel-nKYPtc-g.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,EAAA,GAAA,QAAA,YAAY,IAAI,EAAE;AACpB,SAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAG/B,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;;AAK7B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}