@ait-co/devtools 0.1.143 → 0.2.0

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 (223) hide show
  1. package/README.en.md +65 -163
  2. package/README.md +65 -192
  3. package/dist/in-app/auto.d.ts +1 -138
  4. package/dist/in-app/auto.js +28 -1102
  5. package/dist/in-app/auto.js.map +1 -1
  6. package/dist/in-app/index.d.ts +38 -547
  7. package/dist/in-app/index.d.ts.map +1 -1
  8. package/dist/in-app/index.js +62 -939
  9. package/dist/in-app/index.js.map +1 -1
  10. package/dist/mcp/cli.d.ts +1 -54
  11. package/dist/mcp/cli.js +33 -9720
  12. package/dist/mcp/cli.js.map +1 -1
  13. package/dist/mcp/server.d.ts +1 -88
  14. package/dist/mcp/server.js +36 -1076
  15. package/dist/mcp/server.js.map +1 -1
  16. package/dist/mock/index.d.ts +19 -20
  17. package/dist/mock/index.d.ts.map +1 -1
  18. package/dist/mock/index.js.map +1 -1
  19. package/dist/optional-peers-CDEFhlhJ.cjs +131 -0
  20. package/dist/optional-peers-CDEFhlhJ.cjs.map +1 -0
  21. package/dist/optional-peers-FdVbUJst.js +96 -0
  22. package/dist/optional-peers-FdVbUJst.js.map +1 -0
  23. package/dist/panel/index.js +1 -103
  24. package/dist/panel/index.js.map +1 -1
  25. package/dist/relay-url-store-CPZAn-T5.js +107 -0
  26. package/dist/relay-url-store-CPZAn-T5.js.map +1 -0
  27. package/dist/relay-url-store-DLjlvMSA.cjs +108 -0
  28. package/dist/relay-url-store-DLjlvMSA.cjs.map +1 -0
  29. package/dist/stubs/bin-devtools-mcp.js +58 -0
  30. package/dist/stubs/bin-devtools-mcp.js.map +1 -0
  31. package/dist/stubs/bin-devtools-test.d.ts +2 -0
  32. package/dist/stubs/bin-devtools-test.js +55 -0
  33. package/dist/stubs/bin-devtools-test.js.map +1 -0
  34. package/dist/test-runner/config.d.ts +1 -231
  35. package/dist/test-runner/config.js +41 -45
  36. package/dist/test-runner/config.js.map +1 -1
  37. package/dist/{tunnel-BVSMXctM.cjs → tunnel-BKZkOyQp.cjs} +27 -75
  38. package/dist/tunnel-BKZkOyQp.cjs.map +1 -0
  39. package/dist/{tunnel-D7xkimBu.js → tunnel-CqSCIrdU.js} +27 -75
  40. package/dist/tunnel-CqSCIrdU.js.map +1 -0
  41. package/dist/unplugin/index.cjs +17 -30
  42. package/dist/unplugin/index.cjs.map +1 -1
  43. package/dist/unplugin/index.d.cts +34 -5
  44. package/dist/unplugin/index.d.cts.map +1 -1
  45. package/dist/unplugin/index.d.ts +35 -6
  46. package/dist/unplugin/index.d.ts.map +1 -1
  47. package/dist/unplugin/index.js +17 -30
  48. package/dist/unplugin/index.js.map +1 -1
  49. package/dist/unplugin/tunnel.cjs +61 -74
  50. package/dist/unplugin/tunnel.cjs.map +1 -1
  51. package/dist/unplugin/tunnel.d.cts +22 -16
  52. package/dist/unplugin/tunnel.d.cts.map +1 -1
  53. package/dist/unplugin/tunnel.d.ts +22 -16
  54. package/dist/unplugin/tunnel.d.ts.map +1 -1
  55. package/dist/unplugin/tunnel.js +61 -74
  56. package/dist/unplugin/tunnel.js.map +1 -1
  57. package/package.json +17 -22
  58. package/dist/attach-orchestrator-0F0m_UqQ.js +0 -1845
  59. package/dist/attach-orchestrator-0F0m_UqQ.js.map +0 -1
  60. package/dist/attach-orchestrator-D65KxFy_.js +0 -1831
  61. package/dist/attach-orchestrator-D65KxFy_.js.map +0 -1
  62. package/dist/attach-orchestrator-DL3NQ9ca.js +0 -1846
  63. package/dist/attach-orchestrator-DL3NQ9ca.js.map +0 -1
  64. package/dist/bundle-C796JIwG.d.ts +0 -159
  65. package/dist/bundle-C796JIwG.d.ts.map +0 -1
  66. package/dist/capture-DsP525OZ.d.ts +0 -58
  67. package/dist/capture-DsP525OZ.d.ts.map +0 -1
  68. package/dist/cdp-connection-rP1WdnH5.d.ts +0 -287
  69. package/dist/cdp-connection-rP1WdnH5.d.ts.map +0 -1
  70. package/dist/cell-BaLvusOl.js +0 -68
  71. package/dist/cell-BaLvusOl.js.map +0 -1
  72. package/dist/cell-CBUS3-nT.js +0 -274
  73. package/dist/cell-CBUS3-nT.js.map +0 -1
  74. package/dist/cell-EBKKpAAT.js +0 -307
  75. package/dist/cell-EBKKpAAT.js.map +0 -1
  76. package/dist/chii-relay-BZ3HqWL5.js +0 -304
  77. package/dist/chii-relay-BZ3HqWL5.js.map +0 -1
  78. package/dist/chii-relay-D7eK2acz.cjs +0 -304
  79. package/dist/chii-relay-D7eK2acz.cjs.map +0 -1
  80. package/dist/debug-server-B3ABDrRI.js +0 -456
  81. package/dist/debug-server-B3ABDrRI.js.map +0 -1
  82. package/dist/debug-server-BWhwrVXa.js +0 -1158
  83. package/dist/debug-server-BWhwrVXa.js.map +0 -1
  84. package/dist/debug-server-CfQNxxGW.js +0 -600
  85. package/dist/debug-server-CfQNxxGW.js.map +0 -1
  86. package/dist/deeplink-B5-Hxu0Q.js +0 -62
  87. package/dist/deeplink-B5-Hxu0Q.js.map +0 -1
  88. package/dist/deeplink-BpO9qc-D.js +0 -62
  89. package/dist/deeplink-BpO9qc-D.js.map +0 -1
  90. package/dist/deeplink-BzdbA1gV.cjs +0 -62
  91. package/dist/deeplink-BzdbA1gV.cjs.map +0 -1
  92. package/dist/deeplink-DCScMYcp.cjs +0 -62
  93. package/dist/deeplink-DCScMYcp.cjs.map +0 -1
  94. package/dist/devtools-opener-3Drge_RJ.js +0 -75
  95. package/dist/devtools-opener-3Drge_RJ.js.map +0 -1
  96. package/dist/devtools-opener-B8nxrxqu.js +0 -71
  97. package/dist/devtools-opener-B8nxrxqu.js.map +0 -1
  98. package/dist/devtools-opener-BDY0w3_0.cjs +0 -68
  99. package/dist/devtools-opener-BDY0w3_0.cjs.map +0 -1
  100. package/dist/devtools-opener-BTl5A6Cd.js +0 -71
  101. package/dist/devtools-opener-BTl5A6Cd.js.map +0 -1
  102. package/dist/devtools-opener-CJpEsXXQ.js +0 -76
  103. package/dist/devtools-opener-CJpEsXXQ.js.map +0 -1
  104. package/dist/devtools-opener-CxtryS8c.js +0 -75
  105. package/dist/devtools-opener-CxtryS8c.js.map +0 -1
  106. package/dist/devtools-opener-iv1OwfJN.cjs +0 -68
  107. package/dist/devtools-opener-iv1OwfJN.cjs.map +0 -1
  108. package/dist/in-app/auto.d.ts.map +0 -1
  109. package/dist/mcp/cli.d.ts.map +0 -1
  110. package/dist/mcp/server.d.ts.map +0 -1
  111. package/dist/pool-DcaaOwUq.d.ts +0 -14761
  112. package/dist/pool-DcaaOwUq.d.ts.map +0 -1
  113. package/dist/qr-http-server--gl2-WKc.cjs +0 -1644
  114. package/dist/qr-http-server--gl2-WKc.cjs.map +0 -1
  115. package/dist/qr-http-server-Bb7lMeqi.js +0 -1644
  116. package/dist/qr-http-server-Bb7lMeqi.js.map +0 -1
  117. package/dist/qr-http-server-C_lqOrgc.js +0 -1644
  118. package/dist/qr-http-server-C_lqOrgc.js.map +0 -1
  119. package/dist/qr-http-server-CopuMbub.js +0 -1644
  120. package/dist/qr-http-server-CopuMbub.js.map +0 -1
  121. package/dist/qr-http-server-D-Off6K1.cjs +0 -1644
  122. package/dist/qr-http-server-D-Off6K1.cjs.map +0 -1
  123. package/dist/qr-http-server-DrbIVDjO.js +0 -1645
  124. package/dist/qr-http-server-DrbIVDjO.js.map +0 -1
  125. package/dist/qr-http-server-n1twN18z.js +0 -1644
  126. package/dist/qr-http-server-n1twN18z.js.map +0 -1
  127. package/dist/relay-factory-N9QobQxG.js +0 -206
  128. package/dist/relay-factory-N9QobQxG.js.map +0 -1
  129. package/dist/relay-secret-store-BPhN1upr.js +0 -240
  130. package/dist/relay-secret-store-BPhN1upr.js.map +0 -1
  131. package/dist/relay-secret-store-Bmyleu0A.js +0 -154
  132. package/dist/relay-secret-store-Bmyleu0A.js.map +0 -1
  133. package/dist/relay-secret-store-CQenfcSL.js +0 -154
  134. package/dist/relay-secret-store-CQenfcSL.js.map +0 -1
  135. package/dist/relay-secret-store-DKxs7zwq.js +0 -153
  136. package/dist/relay-secret-store-DKxs7zwq.js.map +0 -1
  137. package/dist/relay-secret-store-DWKdV-eY.cjs +0 -241
  138. package/dist/relay-secret-store-DWKdV-eY.cjs.map +0 -1
  139. package/dist/relay-secret-store-WJ8EGkIl.js +0 -153
  140. package/dist/relay-secret-store-WJ8EGkIl.js.map +0 -1
  141. package/dist/relay-url-store-1FGuSYAn.cjs +0 -115
  142. package/dist/relay-url-store-1FGuSYAn.cjs.map +0 -1
  143. package/dist/relay-url-store-BR2XodiO.js +0 -123
  144. package/dist/relay-url-store-BR2XodiO.js.map +0 -1
  145. package/dist/relay-url-store-Bskcyeg8.js +0 -114
  146. package/dist/relay-url-store-Bskcyeg8.js.map +0 -1
  147. package/dist/relay-url-store-CH63fVCm.js +0 -122
  148. package/dist/relay-url-store-CH63fVCm.js.map +0 -1
  149. package/dist/relay-url-store-DaY1QPes.js +0 -123
  150. package/dist/relay-url-store-DaY1QPes.js.map +0 -1
  151. package/dist/relay-url-store-xmUuTjXA.js +0 -122
  152. package/dist/relay-url-store-xmUuTjXA.js.map +0 -1
  153. package/dist/relay-worker-B5HKkGUY.js +0 -832
  154. package/dist/relay-worker-B5HKkGUY.js.map +0 -1
  155. package/dist/relay-worker-YdlpZQl9.d.ts +0 -214
  156. package/dist/relay-worker-YdlpZQl9.d.ts.map +0 -1
  157. package/dist/rolldown-runtime-DGkTqVfb.js +0 -15
  158. package/dist/rolldown-runtime-DUslC3ob.js +0 -14
  159. package/dist/runtime-kn9DxOeg.d.ts +0 -249
  160. package/dist/runtime-kn9DxOeg.d.ts.map +0 -1
  161. package/dist/test-runner/bin.js +0 -2584
  162. package/dist/test-runner/bin.js.map +0 -1
  163. package/dist/test-runner/bridge-stub.d.ts +0 -125
  164. package/dist/test-runner/bridge-stub.d.ts.map +0 -1
  165. package/dist/test-runner/bridge-stub.js +0 -92
  166. package/dist/test-runner/bridge-stub.js.map +0 -1
  167. package/dist/test-runner/bundle.d.ts +0 -2
  168. package/dist/test-runner/bundle.js +0 -439
  169. package/dist/test-runner/bundle.js.map +0 -1
  170. package/dist/test-runner/capture.d.ts +0 -2
  171. package/dist/test-runner/capture.js +0 -44
  172. package/dist/test-runner/capture.js.map +0 -1
  173. package/dist/test-runner/config.d.ts.map +0 -1
  174. package/dist/test-runner/method-pace.d.ts +0 -82
  175. package/dist/test-runner/method-pace.d.ts.map +0 -1
  176. package/dist/test-runner/method-pace.js +0 -120
  177. package/dist/test-runner/method-pace.js.map +0 -1
  178. package/dist/test-runner/pool.d.ts +0 -2
  179. package/dist/test-runner/pool.js +0 -136
  180. package/dist/test-runner/pool.js.map +0 -1
  181. package/dist/test-runner/relay-factory.d.ts +0 -11245
  182. package/dist/test-runner/relay-factory.d.ts.map +0 -1
  183. package/dist/test-runner/relay-factory.js +0 -206
  184. package/dist/test-runner/relay-factory.js.map +0 -1
  185. package/dist/test-runner/relay-worker.d.ts +0 -2
  186. package/dist/test-runner/relay-worker.js +0 -2
  187. package/dist/test-runner/report.d.ts +0 -163
  188. package/dist/test-runner/report.d.ts.map +0 -1
  189. package/dist/test-runner/report.js +0 -198
  190. package/dist/test-runner/report.js.map +0 -1
  191. package/dist/test-runner/rpc.d.ts +0 -56
  192. package/dist/test-runner/rpc.d.ts.map +0 -1
  193. package/dist/test-runner/rpc.js +0 -98
  194. package/dist/test-runner/rpc.js.map +0 -1
  195. package/dist/test-runner/runtime.d.ts +0 -2
  196. package/dist/test-runner/runtime.js +0 -659
  197. package/dist/test-runner/runtime.js.map +0 -1
  198. package/dist/test-runner/task-graph.d.ts +0 -38
  199. package/dist/test-runner/task-graph.d.ts.map +0 -1
  200. package/dist/test-runner/task-graph.js +0 -182
  201. package/dist/test-runner/task-graph.js.map +0 -1
  202. package/dist/throttle-DKKzX1qC.js +0 -59
  203. package/dist/throttle-DKKzX1qC.js.map +0 -1
  204. package/dist/totp-95OAa20j.js +0 -64
  205. package/dist/totp-95OAa20j.js.map +0 -1
  206. package/dist/totp-BjtoQNfu.cjs +0 -64
  207. package/dist/totp-BjtoQNfu.cjs.map +0 -1
  208. package/dist/totp-CZLLKfOC.js +0 -200
  209. package/dist/totp-CZLLKfOC.js.map +0 -1
  210. package/dist/totp-DAxys-r0.js +0 -199
  211. package/dist/totp-DAxys-r0.js.map +0 -1
  212. package/dist/totp-DIbrZtI7.js +0 -189
  213. package/dist/totp-DIbrZtI7.js.map +0 -1
  214. package/dist/totp-Df252ZdA.cjs +0 -192
  215. package/dist/totp-Df252ZdA.cjs.map +0 -1
  216. package/dist/totp-DfekTBk3.js +0 -211
  217. package/dist/totp-DfekTBk3.js.map +0 -1
  218. package/dist/totp-Dwft0Kz7.js +0 -3
  219. package/dist/totp-WY6l0ysP.js +0 -190
  220. package/dist/totp-WY6l0ysP.js.map +0 -1
  221. package/dist/tunnel-BVSMXctM.cjs.map +0 -1
  222. package/dist/tunnel-D7xkimBu.js.map +0 -1
  223. /package/dist/{test-runner/bin.d.ts → stubs/bin-devtools-mcp.d.ts} +0 -0
@@ -1,3 +1,4 @@
1
+ import { a as hasDebugger, i as hasDebugConsole, n as INSTALL_HINT, o as hasInAppWiring, r as buildInAppSnippet, t as DEBUGGER_DEV_BRIDGE_ID } from "../optional-peers-FdVbUJst.js";
1
2
  import { fileURLToPath } from "node:url";
2
3
  import { createUnplugin } from "unplugin";
3
4
  //#region src/shared/parent-watcher.ts
@@ -138,7 +139,9 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
138
139
  const shouldEnable = isDev;
139
140
  const shouldMock = shouldEnable && (options?.mock ?? isDev);
140
141
  const shouldPanel = shouldEnable && (options?.panel ?? true);
141
- const shouldInApp = shouldEnable && (options?.inApp ?? true);
142
+ const debugConsoleInstalled = hasDebugConsole();
143
+ const shouldInApp = shouldEnable && (options?.inApp ?? true) && debugConsoleInstalled;
144
+ if (shouldEnable && options?.inApp === true && !debugConsoleInstalled) console.warn(`[@ait-co/devtools] inApp: @ait-co/debug-console이 없어 in-app attach를 주입하지 않습니다. 설치: ${INSTALL_HINT}`);
142
145
  const shouldMcp = shouldEnable && (options?.mcp ?? false);
143
146
  let lastState = null;
144
147
  const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);
@@ -166,16 +169,8 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
166
169
  result = `import '@ait-co/devtools/panel';\n${result}`;
167
170
  changed = true;
168
171
  }
169
- if (shouldInApp && !code.includes("@ait-co/devtools/in-app")) {
170
- result = `${[
171
- "// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요",
172
- "if (typeof window !== 'undefined') {",
173
- " const __ait_p = new URLSearchParams(window.location.search);",
174
- " if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {",
175
- " void import('@ait-co/devtools/in-app').then((m) => m.maybeAttach());",
176
- " }",
177
- "}"
178
- ].join("\n")}\n${result}`;
172
+ if (shouldInApp && !hasInAppWiring(code)) {
173
+ result = `${buildInAppSnippet()}\n${result}`;
179
174
  changed = true;
180
175
  }
181
176
  return changed ? result : null;
@@ -231,7 +226,6 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
231
226
  });
232
227
  if (shouldTunnel) {
233
228
  let tunnel = null;
234
- let relayTunnel = null;
235
229
  let relay = null;
236
230
  let qrDashboard = null;
237
231
  let relayUrlDeleteFn = null;
@@ -244,23 +238,19 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
244
238
  console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
245
239
  return;
246
240
  }
247
- import("../tunnel-D7xkimBu.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
241
+ import("../tunnel-CqSCIrdU.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
248
242
  const t = await startQuickTunnel(port);
249
243
  tunnel = t;
250
244
  let relayWssUrl;
251
245
  let relayHttpUrl;
252
246
  let relayLocalHttpUrl;
253
- if (tunnelConfig.cdp) try {
254
- const { ensureRelaySecret } = await import("../relay-secret-store-BPhN1upr.js");
255
- await ensureRelaySecret({ projectRoot: server.config.root });
256
- const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import("../totp-DIbrZtI7.js");
257
- assertRelayAuthConfigured();
258
- const verifyAuth = buildRelayVerifyAuth();
259
- const { startChiiRelay } = await import("../chii-relay-BZ3HqWL5.js");
247
+ if (tunnelConfig.cdp && !hasDebugger()) console.warn(`[@ait-co/devtools] tunnel: @ait-co/debugger가 없어 CDP relay를 건너뜁니다 — 화면 미리보기는 그대로 동작합니다. 설치: ${INSTALL_HINT}`);
248
+ else if (tunnelConfig.cdp) try {
249
+ const { startDevServerCdpRelay } = await import(DEBUGGER_DEV_BRIDGE_ID);
260
250
  let lastAuthRejectWarnAt = 0;
261
- const r = await startChiiRelay({
262
- port: 0,
263
- verifyAuth,
251
+ const r = await startDevServerCdpRelay({
252
+ projectRoot: server.config.root,
253
+ openTunnel: (localPort) => startQuickTunnel(localPort),
264
254
  onAuthReject: () => {
265
255
  const nowMs = Date.now();
266
256
  if (nowMs - lastAuthRejectWarnAt < 1e4) return;
@@ -269,11 +259,9 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
269
259
  }
270
260
  });
271
261
  relay = r;
272
- const rt = await startQuickTunnel(r.port);
273
- relayTunnel = rt;
274
- relayHttpUrl = rt.url;
275
- relayWssUrl = rt.url.replace(/^https:/, "wss:");
276
- relayLocalHttpUrl = `http://127.0.0.1:${r.port}`;
262
+ relayHttpUrl = r.httpUrl;
263
+ relayWssUrl = r.wssUrl;
264
+ relayLocalHttpUrl = r.localHttpUrl;
277
265
  } catch (err) {
278
266
  console.warn(`[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${err instanceof Error ? err.message : String(err)}`);
279
267
  }
@@ -293,7 +281,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
293
281
  navBarTransparent,
294
282
  navBarTheme
295
283
  });
296
- const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-Bskcyeg8.js");
284
+ const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-CPZAn-T5.js");
297
285
  await writeRelayUrls({
298
286
  projectRoot: server.config.root,
299
287
  tunnelBaseUrl: t.url,
@@ -318,7 +306,6 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
318
306
  const cleanup = () => {
319
307
  parentWatcher?.stop();
320
308
  tunnel?.stop();
321
- relayTunnel?.stop();
322
309
  relay?.close();
323
310
  qrDashboard?.close();
324
311
  relayUrlDeleteFn?.(server.config.root);
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.ts"],"sourcesContent":["/**\n * Shared parent-PID watcher — used by both the MCP debug daemon and the\n * unplugin tunnel path to self-terminate when the parent process (e.g. Claude\n * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.\n *\n * Intentionally react-free and Node-stdlib-only so this module is safe to\n * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the\n * install-graph invariant.\n */\n\n// ---------------------------------------------------------------------------\n// isPidAlive — extracted from src/mcp/server-lock.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process\n * exists and we have permission to signal it; throws ESRCH when it doesn't exist.\n */\nexport function isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n // ESRCH = no such process → stale lock.\n // EPERM = process exists but we can't signal it (still alive).\n if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// startParentWatcher — extracted from src/mcp/debug-server.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watcher that detects when the parent process (e.g. Claude\n * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the\n * daemon can self-terminate rather than running as a zombie.\n *\n * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns\n * `{ stop(): void }`, injectable deps for testability.\n *\n * @param onOrphaned - Called once when the parent is gone.\n * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).\n * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).\n * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).\n * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).\n * Detects ppid changes as well as death.\n * @param opts.log - Logger (default `process.stderr.write`).\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startParentWatcher(\n onOrphaned: () => void,\n opts?: {\n intervalMs?: number;\n initialPpid?: number;\n isAlive?: (pid: number) => boolean;\n getPpid?: () => number;\n log?: (msg: string) => void;\n },\n): { stop(): void } {\n const {\n intervalMs = 5_000,\n initialPpid = process.ppid,\n isAlive = isPidAlive,\n getPpid = () => process.ppid,\n log = (msg: string) => process.stderr.write(msg),\n } = opts ?? {};\n\n // PID 1 is init/launchd — running under a process manager or as a detached\n // daemon. There is no meaningful parent to watch; skip the watcher entirely.\n if (initialPpid <= 1) {\n log('[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\\n');\n return { stop() {} };\n }\n\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n\n const currentPpid = getPpid();\n const orphaned = currentPpid !== initialPpid || !isAlive(initialPpid);\n\n if (orphaned) {\n fired = true;\n clearInterval(handle);\n log(\n `[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\\n`,\n );\n onOrphaned();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// startMaxAgeWatchdog — FIX 4: daemon lifetime cap\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watchdog that calls `onExpired` once after `maxAgeMs`\n * milliseconds have elapsed since the watchdog was created.\n *\n * Motivation (issue #571): cloudflared quick-tunnel lifetimes are finite (a\n * few hours). A daemon that has been running for days will have outlived its\n * tunnel regardless of whether the tunnel process exited cleanly. This watchdog\n * caps the daemon's maximum age and forces a fresh start so the tunnel is\n * replaced before it silently expires.\n *\n * @param onExpired - Called once when the maximum age is reached. The caller\n * should call `shutdown()` then `process.exit(0)`.\n * @param opts.maxAgeMs - Maximum daemon lifetime in ms. Default 6 h.\n * @param opts.intervalMs - Check interval in ms. Default 60 000 (1 min).\n * @param opts.now - Time source (injectable for tests). Default `Date.now`.\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startMaxAgeWatchdog(\n onExpired: () => void,\n opts: {\n maxAgeMs?: number;\n intervalMs?: number;\n now?: () => number;\n } = {},\n): { stop(): void } {\n const {\n maxAgeMs = 6 * 60 * 60 * 1_000, // 6 hours\n intervalMs = 60_000,\n now = () => Date.now(),\n } = opts;\n\n const startedAt = now();\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n if (now() - startedAt >= maxAgeMs) {\n fired = true;\n clearInterval(handle);\n onExpired();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n","/**\n * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * In-app debug attach 자동 주입 여부 (default: true).\n *\n * true이면 진입점에 게이트된 dynamic import를 자동 추가한다:\n * `?debug=1` + `relay` URL 파라미터가 모두 존재할 때만 런타임에\n * `@ait-co/devtools/in-app`을 로드하고 `maybeAttach()`를 호출한다.\n *\n * gate가 통과하지 못하면 chunk 자체를 로드하지 않으므로 일반 production\n * 로드에서 dormant하고, 번들러의 DCE 대상이 된다.\n *\n * 소비자가 이미 직접 배선한 경우 중복 주입을 방지하기 위해 파일에\n * `@ait-co/devtools/in-app`이 이미 있으면 자동으로 스킵한다.\n */\n inApp?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * 미니앱의 webViewType (`granite.config.ts`의 `webViewProps.type`)을 빌드 상수\n * `__WEB_VIEW_TYPE__`로 주입한다 (#580). **Vite 전용** (다른 번들러는 무시).\n *\n * 이 상수는 in-app self-report(`@ait-co/devtools/in-app`)가 읽어 launcher(env-2\n * PWA)에 webViewType을 postMessage로 알리고, launcher가 game 타입 미니앱에서\n * 수동 `?navBarType=game` URL 편집 없이 game 모드로 자동 진입하게 한다.\n *\n * 미지정 시 `'partner'`(web-framework `webViewProps.type`의 `@default`)로 주입한다.\n * `game`이면 게임 모드로 자동 진입한다. (granite.config.ts를 config 시점에\n * 자동으로 읽는 것은 TS 모듈 로더가 필요해 보류 — 명시 옵션으로 신뢰성 확보, #580.)\n */\n webViewType?: 'partner' | 'game';\n /**\n * 미니앱의 `granite.config.ts` `navigationBar.transparentBackground` 값\n * (SDK `@apps-in-toss/plugins@2.8.0` 신규 필드, #587). `true`이면 env-2 launcher\n * deep-link에 `&navBarTransparent=1`을 주입해 launcher partner bar가 투명 배경으로\n * 렌더된다. granite.config를 직접 읽지 않는다(version-agnostic, #580 원칙) —\n * 소비자 vite.config가 `graniteConfig.navigationBar?.transparentBackground`를\n * import해 이 옵션으로 넘긴다. 미지정 시 주입 안 함(URL 청정, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * 미니앱의 `granite.config.ts` `navigationBar.theme` 값\n * (SDK `@apps-in-toss/plugins@2.8.0` 신규 필드, #587). `'light'` 또는 `'dark'`이면\n * env-2 launcher deep-link에 `&navBarTheme=<v>`를 주입해 launcher partner bar가\n * 해당 테마 글자/아이콘 색으로 렌더된다. granite.config를 직접 읽지 않는다\n * (version-agnostic, #580 원칙). 미지정 시 주입 안 함(URL 청정, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production에서는\n * 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev;\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n // in-app attach 주입: shouldEnable과 동일하게 dev에서 자동.\n // maybeAttach()가 런타임 gate(Layer B·C)를 자체 검증하므로 dev 항상 주입이 안전하다.\n const shouldInApp = shouldEnable && (options?.inApp ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production, so a production\n // build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n\n // #580: webViewType build constant. Injected as a Vite `define` so the\n // in-app self-report can post it to the launcher for game-mode auto-entry.\n // Defaults to 'partner' (web-framework webViewProps.type @default).\n const webViewType = options?.webViewType ?? 'partner';\n // #587: navigationBar appearance options (SDK 2.8.0 granite.config fields).\n // Forwarded to printTunnelBanner so the launcher deep-link carries the params.\n const navBarTransparent = options?.navBarTransparent;\n const navBarTheme = options?.navBarTheme;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n // panel 또는 inApp 주입 중 하나라도 필요하면 진입점 파일을 transform 대상으로 포함\n if (!shouldPanel && !shouldInApp) return false;\n // 진입점 파일에만 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n let result = code;\n let changed = false;\n\n // 패널 주입: shouldPanel이 활성화되어 있고 아직 import가 없으면 prepend\n if (shouldPanel && !code.includes('@ait-co/devtools/panel')) {\n result = `import '@ait-co/devtools/panel';\\n${result}`;\n changed = true;\n }\n\n // in-app attach 주입: shouldInApp이 활성화되어 있고 아직 배선이 없으면 prepend.\n // 게이트된 dynamic import로 주입 — ?debug=1 + relay 파라미터가 모두 있을 때만\n // 런타임에 @ait-co/devtools/in-app을 로드하고 maybeAttach()를 호출한다.\n // production DCE: URLSearchParams gate가 조건을 false로 평가하면\n // dynamic import 자체가 dead code — 번들러가 제거 가능.\n if (shouldInApp && !code.includes('@ait-co/devtools/in-app')) {\n const inAppSnippet = [\n '// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요',\n \"if (typeof window !== 'undefined') {\",\n ' const __ait_p = new URLSearchParams(window.location.search);',\n \" if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {\",\n \" void import('@ait-co/devtools/in-app').then((m) => m.maybeAttach());\",\n ' }',\n '}',\n ].join('\\n');\n result = `${inAppSnippet}\\n${result}`;\n changed = true;\n }\n\n return changed ? result : null;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n // #580: inject the webViewType build constant for every Vite build so\n // the in-app self-report (@ait-co/devtools/in-app) can read it and post\n // it to the launcher (env-2 PWA) for game-mode auto-entry. JSON.stringify\n // makes it a string literal at the define substitution site.\n const define = { __WEB_VIEW_TYPE__: JSON.stringify(webViewType) };\n if (!shouldTunnel) return { define };\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { define, server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n // LOCAL relay base — loopback URL, safe to surface (issue #530).\n let relayLocalHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n // LOCAL relay base for MCP inspector URL assembly (issue #530):\n // the relay process runs on this machine, so the inspector\n // front_end + client WS can use the loopback address directly —\n // no tunnel round-trip for the developer's browser.\n // Safe to surface: loopback URL contains no tunnel host.\n relayLocalHttpUrl = `http://127.0.0.1:${r.port}`;\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n // Read the app name from the project's package.json to add to\n // the launcher deep-link (#498). Failure is silently ignored.\n let tunnelAppName: string | undefined;\n try {\n const { readFileSync } = await import('node:fs');\n const pkgPath = `${server.config.root}/package.json`;\n const pkgRaw = readFileSync(pkgPath, 'utf8');\n const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;\n const rawName = typeof pkg.name === 'string' ? pkg.name : '';\n const stripped = rawName.includes('/')\n ? rawName.slice(rawName.indexOf('/') + 1)\n : rawName;\n tunnelAppName = stripped.trim() || undefined;\n } catch {\n // Silently ignore — fail-open.\n }\n\n await printTunnelBanner(t.url, {\n qr: tunnelConfig.qr,\n relayWssUrl,\n name: tunnelAppName,\n webViewType,\n navBarTransparent,\n navBarTheme,\n });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n // Issue #530: local relay base for inspector URL (loopback, no tunnel host).\n ...(relayLocalHttpUrl !== undefined ? { relayLocalUrl: relayLocalHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n name: tunnelAppName,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.ts"],"sourcesContent":["/**\n * Shared parent-PID watcher — used by both the MCP debug daemon and the\n * unplugin tunnel path to self-terminate when the parent process (e.g. Claude\n * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.\n *\n * Intentionally react-free and Node-stdlib-only so this module is safe to\n * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the\n * install-graph invariant.\n */\n\n// ---------------------------------------------------------------------------\n// isPidAlive — extracted from src/mcp/server-lock.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process\n * exists and we have permission to signal it; throws ESRCH when it doesn't exist.\n */\nexport function isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n // ESRCH = no such process → stale lock.\n // EPERM = process exists but we can't signal it (still alive).\n if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// startParentWatcher — extracted from src/mcp/debug-server.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watcher that detects when the parent process (e.g. Claude\n * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the\n * daemon can self-terminate rather than running as a zombie.\n *\n * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns\n * `{ stop(): void }`, injectable deps for testability.\n *\n * @param onOrphaned - Called once when the parent is gone.\n * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).\n * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).\n * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).\n * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).\n * Detects ppid changes as well as death.\n * @param opts.log - Logger (default `process.stderr.write`).\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startParentWatcher(\n onOrphaned: () => void,\n opts?: {\n intervalMs?: number;\n initialPpid?: number;\n isAlive?: (pid: number) => boolean;\n getPpid?: () => number;\n log?: (msg: string) => void;\n },\n): { stop(): void } {\n const {\n intervalMs = 5_000,\n initialPpid = process.ppid,\n isAlive = isPidAlive,\n getPpid = () => process.ppid,\n log = (msg: string) => process.stderr.write(msg),\n } = opts ?? {};\n\n // PID 1 is init/launchd — running under a process manager or as a detached\n // daemon. There is no meaningful parent to watch; skip the watcher entirely.\n if (initialPpid <= 1) {\n log('[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\\n');\n return { stop() {} };\n }\n\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n\n const currentPpid = getPpid();\n const orphaned = currentPpid !== initialPpid || !isAlive(initialPpid);\n\n if (orphaned) {\n fired = true;\n clearInterval(handle);\n log(\n `[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\\n`,\n );\n onOrphaned();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// startMaxAgeWatchdog — FIX 4: daemon lifetime cap\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watchdog that calls `onExpired` once after `maxAgeMs`\n * milliseconds have elapsed since the watchdog was created.\n *\n * Motivation (issue #571): cloudflared quick-tunnel lifetimes are finite (a\n * few hours). A daemon that has been running for days will have outlived its\n * tunnel regardless of whether the tunnel process exited cleanly. This watchdog\n * caps the daemon's maximum age and forces a fresh start so the tunnel is\n * replaced before it silently expires.\n *\n * @param onExpired - Called once when the maximum age is reached. The caller\n * should call `shutdown()` then `process.exit(0)`.\n * @param opts.maxAgeMs - Maximum daemon lifetime in ms. Default 6 h.\n * @param opts.intervalMs - Check interval in ms. Default 60 000 (1 min).\n * @param opts.now - Time source (injectable for tests). Default `Date.now`.\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startMaxAgeWatchdog(\n onExpired: () => void,\n opts: {\n maxAgeMs?: number;\n intervalMs?: number;\n now?: () => number;\n } = {},\n): { stop(): void } {\n const {\n maxAgeMs = 6 * 60 * 60 * 1_000, // 6 hours\n intervalMs = 60_000,\n now = () => Date.now(),\n } = opts;\n\n const startedAt = now();\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n if (now() - startedAt >= maxAgeMs) {\n fired = true;\n clearInterval(handle);\n onExpired();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n","/**\n * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\nimport {\n buildInAppSnippet,\n DEBUGGER_DEV_BRIDGE_ID,\n hasDebugConsole,\n hasDebugger,\n hasInAppWiring,\n INSTALL_HINT,\n} from './optional-peers.js';\n\n/**\n * The slice of `startQuickTunnel`'s handle that `@ait-co/debugger`'s relay\n * bootstrap consumes. Declared structurally rather than imported from the peer\n * so this module never type-depends on an OPTIONAL peer being installed.\n */\ninterface QuickTunnelLike {\n /** Public `https://` base URL. SECRET-HANDLING: carries the tunnel host — never log. */\n url: string;\n /** Idempotent teardown. */\n stop: () => void;\n}\n\n/**\n * The slice of `@ait-co/debugger/dev-bridge`'s `startDevServerCdpRelay` handle\n * this plugin uses. Structural for the same reason as {@link QuickTunnelLike}.\n */\ninterface DevServerCdpRelayLike {\n /** `http://127.0.0.1:<port>` — loopback, safe to surface (issue #530). */\n localHttpUrl: string;\n /** Public `https://` relay base. SECRET-HANDLING: never log. */\n httpUrl: string;\n /** Public `wss://` relay URL the launcher QR carries. SECRET-HANDLING: never log. */\n wssUrl: string;\n /** Tears down the relay tunnel and then the relay. Idempotent, never rejects. */\n close: () => Promise<void>;\n}\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * In-app debug attach 자동 주입 여부 (default: true).\n *\n * true이면 진입점에 게이트된 dynamic import를 자동 추가한다:\n * `?debug=1` + `relay` URL 파라미터가 모두 존재할 때만 런타임에\n * `@ait-co/debug-console`을 로드하고 `maybeAttach()`를 호출한다.\n *\n * gate가 통과하지 못하면 chunk 자체를 로드하지 않으므로 일반 production\n * 로드에서 dormant하고, 번들러의 DCE 대상이 된다.\n *\n * `@ait-co/debug-console`은 **optional peer**다 (#817). 설치돼 있지 않으면\n * 주입 자체를 하지 않으므로 attach 코드가 번들에 구조적으로 들어갈 수 없다 —\n * 이게 디버그 표면 보안 스코프의 기술적 강제 지점이다. 환경 1(브라우저 mock)만\n * 쓰는 소비자는 아무것도 추가로 설치하지 않아도 된다.\n *\n * 소비자가 이미 직접 배선한 경우 중복 주입을 방지하기 위해 파일에\n * `@ait-co/debug-console`(또는 분리 전 `@ait-co/devtools/in-app`)이 이미 있으면\n * 자동으로 스킵한다.\n */\n inApp?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * 미니앱의 webViewType (`granite.config.ts`의 `webViewProps.type`)을 빌드 상수\n * `__WEB_VIEW_TYPE__`로 주입한다 (#580). **Vite 전용** (다른 번들러는 무시).\n *\n * 이 상수는 in-app self-report(`@ait-co/devtools/in-app`)가 읽어 launcher(env-2\n * PWA)에 webViewType을 postMessage로 알리고, launcher가 game 타입 미니앱에서\n * 수동 `?navBarType=game` URL 편집 없이 game 모드로 자동 진입하게 한다.\n *\n * 미지정 시 `'partner'`(web-framework `webViewProps.type`의 `@default`)로 주입한다.\n * `game`이면 게임 모드로 자동 진입한다. (granite.config.ts를 config 시점에\n * 자동으로 읽는 것은 TS 모듈 로더가 필요해 보류 — 명시 옵션으로 신뢰성 확보, #580.)\n */\n webViewType?: 'partner' | 'game';\n /**\n * 미니앱의 `granite.config.ts` `navigationBar.transparentBackground` 값\n * (SDK `@apps-in-toss/plugins@2.8.0` 신규 필드, #587). `true`이면 env-2 launcher\n * deep-link에 `&navBarTransparent=1`을 주입해 launcher partner bar가 투명 배경으로\n * 렌더된다. granite.config를 직접 읽지 않는다(version-agnostic, #580 원칙) —\n * 소비자 vite.config가 `graniteConfig.navigationBar?.transparentBackground`를\n * import해 이 옵션으로 넘긴다. 미지정 시 주입 안 함(URL 청정, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * 미니앱의 `granite.config.ts` `navigationBar.theme` 값\n * (SDK `@apps-in-toss/plugins@2.8.0` 신규 필드, #587). `'light'` 또는 `'dark'`이면\n * env-2 launcher deep-link에 `&navBarTheme=<v>`를 주입해 launcher partner bar가\n * 해당 테마 글자/아이콘 색으로 렌더된다. granite.config를 직접 읽지 않는다\n * (version-agnostic, #580 원칙). 미지정 시 주입 안 함(URL 청정, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production에서는\n * 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n *\n * 이 경로는 **optional peer `@ait-co/debugger`**를 요구한다 (#817).\n * 미설치면 CDP 배선을 건너뛰고 일반 화면 미리보기 터널로 degrade하며,\n * 설치 안내를 한 번 출력한다.\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev;\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n // in-app attach 주입: shouldEnable과 동일하게 dev에서 자동.\n // maybeAttach()가 런타임 gate(Layer B·C)를 자체 검증하므로 dev 항상 주입이 안전하다.\n //\n // #817: 주입 대상인 `@ait-co/debug-console`은 optional peer다. 미설치면 주입\n // 자체를 하지 않는다 — attach 코드가 번들에 구조적으로 못 들어가는 게 보안\n // 스코프의 강제 지점이고, 환경 1만 쓰는 다수 소비자는 아무것도 더 설치하지\n // 않아도 된다. 사용자가 `inApp: true`로 명시 요청했는데 패키지가 없을 때만\n // 안내를 출력한다 (기본값 경로는 조용히 degrade — 상시 nag 금지).\n const debugConsoleInstalled = hasDebugConsole();\n const shouldInApp = shouldEnable && (options?.inApp ?? true) && debugConsoleInstalled;\n if (shouldEnable && options?.inApp === true && !debugConsoleInstalled) {\n console.warn(\n `[@ait-co/devtools] inApp: @ait-co/debug-console이 없어 in-app attach를 주입하지 않습니다. 설치: ${INSTALL_HINT}`,\n );\n }\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production, so a production\n // build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n\n // #580: webViewType build constant. Injected as a Vite `define` so the\n // in-app self-report can post it to the launcher for game-mode auto-entry.\n // Defaults to 'partner' (web-framework webViewProps.type @default).\n const webViewType = options?.webViewType ?? 'partner';\n // #587: navigationBar appearance options (SDK 2.8.0 granite.config fields).\n // Forwarded to printTunnelBanner so the launcher deep-link carries the params.\n const navBarTransparent = options?.navBarTransparent;\n const navBarTheme = options?.navBarTheme;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n // panel 또는 inApp 주입 중 하나라도 필요하면 진입점 파일을 transform 대상으로 포함\n if (!shouldPanel && !shouldInApp) return false;\n // 진입점 파일에만 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n let result = code;\n let changed = false;\n\n // 패널 주입: shouldPanel이 활성화되어 있고 아직 import가 없으면 prepend\n if (shouldPanel && !code.includes('@ait-co/devtools/panel')) {\n result = `import '@ait-co/devtools/panel';\\n${result}`;\n changed = true;\n }\n\n // in-app attach 주입: shouldInApp이 활성화되어 있고 아직 배선이 없으면 prepend.\n // 게이트된 dynamic import로 주입 — ?debug=1 + relay 파라미터가 모두 있을 때만\n // 런타임에 @ait-co/debug-console을 로드하고 maybeAttach()를 호출한다 (#817).\n // production DCE: URLSearchParams gate가 조건을 false로 평가하면\n // dynamic import 자체가 dead code — 번들러가 제거 가능.\n // dedupe는 신·구 specifier를 모두 인정한다 (hasInAppWiring).\n if (shouldInApp && !hasInAppWiring(code)) {\n result = `${buildInAppSnippet()}\\n${result}`;\n changed = true;\n }\n\n return changed ? result : null;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n // #580: inject the webViewType build constant for every Vite build so\n // the in-app self-report (@ait-co/devtools/in-app) can read it and post\n // it to the launcher (env-2 PWA) for game-mode auto-entry. JSON.stringify\n // makes it a string literal at the define substitution site.\n const define = { __WEB_VIEW_TYPE__: JSON.stringify(webViewType) };\n if (!shouldTunnel) return { define };\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { define, server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): the relay handle returned by\n // `@ait-co/debugger`'s bootstrap. Its `close()` owns BOTH the relay\n // tunnel and the relay, so no second handle is tracked here.\n // Fire-and-forget close on teardown.\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n // LOCAL relay base — loopback URL, safe to surface (issue #530).\n let relayLocalHttpUrl: string | undefined;\n // #817: env-2 CDP는 optional peer `@ait-co/debugger`를 요구한다.\n // 미설치면 relay·dashboard 배선을 통째로 건너뛰고 일반 화면\n // 미리보기 터널로 degrade한다 — 사용자가 명시적으로 cdp를 켠\n // 경로이므로 설치 안내를 한 번 출력한다.\n // SECRET-HANDLING: 고정 문구만 — URL·host·코드 없음.\n if (tunnelConfig.cdp && !hasDebugger()) {\n console.warn(\n `[@ait-co/devtools] tunnel: @ait-co/debugger가 없어 CDP relay를 건너뜁니다 — 화면 미리보기는 그대로 동작합니다. 설치: ${INSTALL_HINT}`,\n );\n } else if (tunnelConfig.cdp) {\n try {\n // #818: the whole relay bootstrap is now `@ait-co/debugger`'s.\n // Its `/dev-bridge` entry performs, in one call and in a fixed\n // order, the four steps this file used to run against local\n // `../mcp/*` modules: mint/load the project-local `.ait_relay`\n // TOTP secret, fail fast if relay auth is unconfigured, start\n // the Chii relay behind the TOTP upgrade gate, then open a\n // tunnel to the port it bound.\n //\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade. The secret file is anchored at the\n // nearest package.json above `server.config.root` — the same\n // anchor the MCP daemon resolves read-only, which is why the\n // dev server's root is what gets passed here (issues #394/#396).\n //\n // `cloudflared` stays on THIS side: `openTunnel` is injected\n // so the spawner (and its sanitising error handling) remains\n // the dev-server plugin's, not the daemon's.\n //\n // SECRET-HANDLING: nothing in this block logs the secret, the\n // TOTP code, the tunnel host, or the relay URL.\n const { startDevServerCdpRelay } = (await import(DEBUGGER_DEV_BRIDGE_ID)) as {\n startDevServerCdpRelay: (opts: {\n projectRoot: string;\n openTunnel: (localPort: number) => Promise<QuickTunnelLike>;\n onAuthReject?: (event: { kind: string }) => void;\n }) => Promise<DevServerCdpRelayLike>;\n };\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startDevServerCdpRelay({\n projectRoot: server.config.root,\n openTunnel: (localPort: number) => startQuickTunnel(localPort),\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)',\n );\n },\n });\n // r.close() tears down the relay tunnel AND the relay, in that\n // order — there is no separate handle to keep here any more.\n relay = r;\n // SECRET-HANDLING: httpUrl/wssUrl carry the relay host — stored\n // for the .ait_urls write and the QR below; never logged.\n relayHttpUrl = r.httpUrl;\n relayWssUrl = r.wssUrl;\n // LOCAL relay base for MCP inspector URL assembly (issue #530):\n // the relay process runs on this machine, so the inspector\n // front_end + client WS can use the loopback address directly —\n // no tunnel round-trip for the developer's browser.\n // Safe to surface: loopback URL contains no tunnel host.\n relayLocalHttpUrl = r.localHttpUrl;\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n // Read the app name from the project's package.json to add to\n // the launcher deep-link (#498). Failure is silently ignored.\n let tunnelAppName: string | undefined;\n try {\n const { readFileSync } = await import('node:fs');\n const pkgPath = `${server.config.root}/package.json`;\n const pkgRaw = readFileSync(pkgPath, 'utf8');\n const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;\n const rawName = typeof pkg.name === 'string' ? pkg.name : '';\n const stripped = rawName.includes('/')\n ? rawName.slice(rawName.indexOf('/') + 1)\n : rawName;\n tunnelAppName = stripped.trim() || undefined;\n } catch {\n // Silently ignore — fail-open.\n }\n\n await printTunnelBanner(t.url, {\n qr: tunnelConfig.qr,\n relayWssUrl,\n name: tunnelAppName,\n webViewType,\n navBarTransparent,\n navBarTheme,\n });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import('./relay-url-store.js');\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n // Issue #530: local relay base for inspector URL (loopback, no tunnel host).\n ...(relayLocalHttpUrl !== undefined ? { relayLocalUrl: relayLocalHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n name: tunnelAppName,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
@@ -2,6 +2,42 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_fs = require("node:fs");
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  let node_path = require("node:path");
5
+ //#region src/unplugin/optional-peers.ts
6
+ /**
7
+ * Optional-peer resolution gates for the debug surface (issue #817).
8
+ *
9
+ * The debug surface now ships as two SEPARATE packages, declared here as
10
+ * OPTIONAL peerDependencies of `@ait-co/devtools`:
11
+ *
12
+ * - `@ait-co/debugger` — MCP daemon / test runner / dev-bridge. devDependency
13
+ * or `npx` only; never part of an app bundle.
14
+ * - `@ait-co/debug-console` — on-device attach + eruda console. The ONLY debug
15
+ * package that can legitimately enter an app bundle.
16
+ *
17
+ * Neither may become a hard dependency. The overwhelming majority of consumers
18
+ * use environment 1 only (browser + mock SDK + panel) and must install NOTHING
19
+ * extra — that is the whole point of splitting the packages apart. So every
20
+ * call site reaches them through the resolution gates below and degrades
21
+ * gracefully when they are absent:
22
+ *
23
+ * - env-2 CDP wiring (`tunnel.cdp`) → falls back to the plain screen-preview
24
+ * tunnel (no relay, no dashboard) and prints {@link INSTALL_HINT}.
25
+ * - in-app attach injection → the snippet is simply not injected, so the
26
+ * attach code is STRUCTURALLY ABSENT from the bundle rather than merely
27
+ * gated at runtime. This gate is the technical enforcement point of the
28
+ * debug surface's security scope: no `@ait-co/debug-console` installed
29
+ * means no attach code can reach a bundle at all.
30
+ *
31
+ * The `import.meta.resolve` probe follows the `MOCK_PATH` precedent in
32
+ * `./index.ts`: resolution is attempted once at plugin-load time and a failure
33
+ * is a plain "not installed" signal, never an error.
34
+ *
35
+ * SECRET-HANDLING: nothing here touches a relay URL, tunnel host, TOTP secret
36
+ * or code. The only strings emitted are fixed specifiers and the install hint.
37
+ */
38
+ /** Subpath of `@ait-co/debugger` that the unplugin's dev loop delegates to. */
39
+ const DEBUGGER_DEV_BRIDGE_ID = "@ait-co/debugger/dev-bridge";
40
+ //#endregion
5
41
  //#region src/unplugin/tunnel.ts
6
42
  /**
7
43
  * Cloudflare quick-tunnel helper for the devtools unplugin.
@@ -106,97 +142,48 @@ async function printTunnelBanner(url, opts = {}) {
106
142
  }
107
143
  }
108
144
  /**
109
- * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in
110
- * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path
111
- * does not statically pull the heavy MCP `tools.ts` module graph into the lazy
112
- * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.
113
- *
114
- * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).
115
- * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.
116
- * - CI (`CI=true`/`CI=1`) → no.
117
- */
118
- function canOpenBrowser() {
119
- if (process.env.CI === "true" || process.env.CI === "1") return false;
120
- const platform = process.platform;
121
- if (platform === "darwin" || platform === "win32") return true;
122
- if (platform === "linux") return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);
123
- return false;
124
- }
125
- /**
126
- * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is
145
+ * Env-2 UX parity with env 3 (issue #408): when CDP wiring is on and a GUI is
127
146
  * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps
128
- * + FAQ) that the MCP `start_attach` path serves, and auto-open it in the
147
+ * + FAQ) the debug daemon's `start_attach` path serves, and auto-open it in the
129
148
  * browser. headless / opt-out falls back to the terminal ASCII QR (printed
130
149
  * separately by {@link printTunnelBanner}).
131
150
  *
132
- * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP
133
- * server, the opener) is reached only through dynamic `import()` here, inside
134
- * the already-lazy `tunnel.js` chunk nothing is added to the common build
135
- * graph or the MCP-only install graph.
151
+ * DELEGATED (issue #817): the implementation now lives in `@ait-co/debugger`'s
152
+ * `/dev-bridge` subpath the one cross-repo code delegation of the package
153
+ * split. The dashboard needs the daemon's QR HTTP server, deep-link builder and
154
+ * TOTP minting, all of which moved into that package; keeping a second copy here
155
+ * would fork them. `@ait-co/debugger` is an OPTIONAL peer, so a consumer who
156
+ * never asks for env-2 CDP installs nothing extra.
157
+ *
158
+ * Why this is not a separate plugin: the relay `wss://` URL must exist BEFORE
159
+ * the QR banner is printed (the launcher QR carries `&relay=`), so the relay and
160
+ * the dashboard are order-dependent and stay one composed call in the dev loop.
136
161
  *
137
- * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH
138
- * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds
139
- * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on
140
- * each SSE push / page reload from this closure, the code a phone scans is always
141
- * within its 30 s window no stale code is baked into static HTML.
162
+ * Degradation: when `@ait-co/debugger` is not installed the import fails and we
163
+ * return `undefined` exactly the same "no dashboard" outcome as a closed GUI
164
+ * gate, with the terminal ASCII QR standing alone. We do NOT fall back to a
165
+ * local copy of the daemon code. The caller (`./index.ts`) prints the install
166
+ * hint once when CDP was requested without the package.
142
167
  *
143
168
  * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`
144
- * value/path are NEVER written to stdout/stderr/logs here. They live only inside
145
- * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).
146
- * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).
169
+ * value/path are NEVER written to stdout/stderr/logs — neither here nor in the
170
+ * delegate. The only thing opened/logged is `http://127.0.0.1:<port>` (local).
147
171
  *
148
172
  * @returns the dashboard handle when it started (caller wires `close()` into the
149
- * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,
150
- * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.
173
+ * tunnel cleanup), or `undefined` when skipped (package absent, no relay,
174
+ * `qr:false`, headless, opt-out, or a start failure) — in which case the ASCII
175
+ * QR fallback stands alone.
151
176
  */
152
177
  async function startTunnelDashboard(opts) {
153
- const log = opts.log ?? ((m) => console.log(m));
154
178
  if (!opts.relayWssUrl) return void 0;
155
179
  if (opts.qr === false) return void 0;
156
- const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("../devtools-opener-BDY0w3_0.cjs"));
157
- if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
158
- const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-D-Off6K1.cjs"));
159
- const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("../deeplink-DCScMYcp.cjs"));
160
- const { generateTotp } = await Promise.resolve().then(() => require("../totp-BjtoQNfu.cjs"));
161
- const getDashboardState = () => {
162
- const secret = process.env.AIT_DEBUG_TOTP_SECRET;
163
- const totpCode = secret ? generateTotp(secret, Date.now()) : void 0;
164
- const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, { name: opts.name });
165
- return {
166
- tunnel: {
167
- up: true,
168
- wssUrl: opts.relayWssUrl
169
- },
170
- pages: null,
171
- attachUrl,
172
- inspectorUrl: null,
173
- mode: "relay-mobile",
174
- phase: "active"
175
- };
176
- };
177
- let server;
180
+ let devBridge;
178
181
  try {
179
- server = await startQrHttpServer(getDashboardState);
182
+ devBridge = await import(DEBUGGER_DEV_BRIDGE_ID);
180
183
  } catch {
181
184
  return;
182
185
  }
183
- let totpRefreshHandle = setInterval(() => {
184
- server.notifyStateChange();
185
- }, 2e4);
186
- totpRefreshHandle.unref();
187
- const dashboardUrl = `http://127.0.0.1:${server.port}`;
188
- const { openUrlInBrowser } = await Promise.resolve().then(() => require("../devtools-opener-BDY0w3_0.cjs"));
189
- log(openUrlInBrowser(dashboardUrl) ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}` : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`);
190
- return {
191
- url: dashboardUrl,
192
- close: () => {
193
- if (totpRefreshHandle) {
194
- clearInterval(totpRefreshHandle);
195
- totpRefreshHandle = null;
196
- }
197
- return server.close();
198
- }
199
- };
186
+ return devBridge.startTunnelDashboard(opts);
200
187
  }
201
188
  /**
202
189
  * Sanitize cloudflared stderr output for error diagnostics (#421).
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.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 * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\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 `opts.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 *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\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, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\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 * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\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 `start_attach` 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 name: opts.name,\n });\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 // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n // phase (#730): this dev-tunnel dashboard has no CLI run lifecycle or\n // daemon shutdown signal to drive — it stays 'active' for its lifetime.\n phase: 'active' as const,\n };\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 // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\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: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\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\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;;AA6CpB,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsErB,SAAgB,sBACd,WACA,aACQ;CAER,MAAM,OACJ,OAAO,gBAAgB,WAAW,EAAE,aAAa,aAAa,GAAI,eAAe,EAAE;CAGrF,IAAI,MADS,GAAG,aAAa,OAAO,mBAAmB,UAAU;AAEjE,KAAI,KAAK,YACP,QAAO,kBAAkB,mBAAmB,KAAK,YAAY;AAE/D,KAAI,KAAK,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GAClD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,KAAK,gBAAgB,OACvB,QAAO;AAET,KAAI,KAAK,sBAAsB,KAC7B,QAAO;AAET,KAAI,KAAK,gBAAgB,WAAW,KAAK,gBAAgB,OACvD,QAAO,gBAAgB,KAAK;AAE9B,QAAO;;;;;;;;AAST,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK;EAC1C,aAAa,KAAK;EAClB,MAAM,KAAK;EACX,aAAa,KAAK;EAClB,mBAAmB,KAAK;EACxB,aAAa,KAAK;EACnB,CAAC;AAoBF,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DT,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,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,kCAAA,CAAA;AAEzC,KAAI,EADa,KAAK,qBAAqB,CAAC,wBAAwB,IAAI,gBAAgB,IACzE,CAAE,QAAO,KAAA;CAExB,MAAM,EAAE,sBAAsB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,iCAAA,CAAA;CACpC,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,2BAAA,CAAA;CACzC,MAAM,EAAE,iBAAiB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,uBAAA,CAAA;CAQ/B,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,UAAU,EACnF,MAAM,KAAK,MACZ,CAAC;AAUF,SAAO;GACL,QAAQ;IAAE,IAAI;IAAM,QAAQ,KAAK;IAAa;GAC9C,OAAO;GACP;GACA,cAAc;GACd,MAAM;GAGN,OAAO;GACR;;CAGH,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,kBAAkB,kBAAkB;SAC7C;AAGN;;CAWF,IAAI,oBAA2D,kBAAkB;AAC/E,SAAO,mBAAmB;IAFK,IAGL;AAC5B,mBAAkB,OAAO;CAEzB,MAAM,eAAe,oBAAoB,OAAO;CAEhD,MAAM,EAAE,qBAAqB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,kCAAA,CAAA;AAInC,KAHe,iBAAiB,aAAa,GAKvC,+CAA+C,iBAC/C,gDAAgD,eACrD;AAED,QAAO;EACL,KAAK;EACL,aAAa;AACX,OAAI,mBAAmB;AACrB,kBAAc,kBAAkB;AAChC,wBAAoB;;AAEtB,UAAO,OAAO,OAAO;;EAExB;;;;;;;;;;;;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,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;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"}
1
+ {"version":3,"file":"tunnel.cjs","names":[],"sources":["../../src/unplugin/optional-peers.ts","../../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Optional-peer resolution gates for the debug surface (issue #817).\n *\n * The debug surface now ships as two SEPARATE packages, declared here as\n * OPTIONAL peerDependencies of `@ait-co/devtools`:\n *\n * - `@ait-co/debugger` — MCP daemon / test runner / dev-bridge. devDependency\n * or `npx` only; never part of an app bundle.\n * - `@ait-co/debug-console` — on-device attach + eruda console. The ONLY debug\n * package that can legitimately enter an app bundle.\n *\n * Neither may become a hard dependency. The overwhelming majority of consumers\n * use environment 1 only (browser + mock SDK + panel) and must install NOTHING\n * extra — that is the whole point of splitting the packages apart. So every\n * call site reaches them through the resolution gates below and degrades\n * gracefully when they are absent:\n *\n * - env-2 CDP wiring (`tunnel.cdp`) → falls back to the plain screen-preview\n * tunnel (no relay, no dashboard) and prints {@link INSTALL_HINT}.\n * - in-app attach injection → the snippet is simply not injected, so the\n * attach code is STRUCTURALLY ABSENT from the bundle rather than merely\n * gated at runtime. This gate is the technical enforcement point of the\n * debug surface's security scope: no `@ait-co/debug-console` installed\n * means no attach code can reach a bundle at all.\n *\n * The `import.meta.resolve` probe follows the `MOCK_PATH` precedent in\n * `./index.ts`: resolution is attempted once at plugin-load time and a failure\n * is a plain \"not installed\" signal, never an error.\n *\n * SECRET-HANDLING: nothing here touches a relay URL, tunnel host, TOTP secret\n * or code. The only strings emitted are fixed specifiers and the install hint.\n */\n\n/** Subpath of `@ait-co/debugger` that the unplugin's dev loop delegates to. */\nexport const DEBUGGER_DEV_BRIDGE_ID = '@ait-co/debugger/dev-bridge';\n\n/** The on-device attach package injected into a consumer entry point. */\nexport const DEBUG_CONSOLE_ID = '@ait-co/debug-console';\n\n/**\n * The pre-split in-app specifier. Still recognised for DEDUPE only — a consumer\n * who wired `@ait-co/devtools/in-app` by hand must not also receive the new\n * auto-injected snippet. It is never injected any more.\n */\nexport const LEGACY_IN_APP_ID = '@ait-co/devtools/in-app';\n\n/** One-line install hint printed whenever a gate closes on a missing package. */\nexport const INSTALL_HINT = 'pnpm add -D @ait-co/debugger @ait-co/debug-console';\n\n/**\n * Returns whether `specifier` resolves from this package's location.\n *\n * `import.meta.resolve` throws (rather than returning a falsy value) for an\n * unresolvable specifier, and is itself absent on a few older runtimes — both\n * are treated as \"not installed\", which is the safe direction: the caller then\n * takes the degraded path instead of importing something that is not there.\n */\nexport function canResolveOptionalPeer(specifier: string): boolean {\n try {\n return typeof import.meta.resolve(specifier) === 'string';\n } catch {\n return false;\n }\n}\n\n/** Whether `@ait-co/debugger`'s dev-bridge is installed (env-2 CDP path). */\nexport function hasDebugger(): boolean {\n return canResolveOptionalPeer(DEBUGGER_DEV_BRIDGE_ID);\n}\n\n/** Whether `@ait-co/debug-console` is installed (in-app attach injection). */\nexport function hasDebugConsole(): boolean {\n return canResolveOptionalPeer(DEBUG_CONSOLE_ID);\n}\n\n/**\n * Whether `code` already wires the in-app debug attach itself.\n *\n * Both the current (`@ait-co/debug-console`) and the pre-split\n * (`@ait-co/devtools/in-app`) specifier count, so a consumer who wired either\n * one by hand never gets a duplicate auto-injection.\n */\nexport function hasInAppWiring(code: string): boolean {\n return code.includes(DEBUG_CONSOLE_ID) || code.includes(LEGACY_IN_APP_ID);\n}\n\n/**\n * The gated dynamic-import snippet prepended to a consumer entry point.\n *\n * The runtime gate (`?debug=1` + a `relay` param) keeps the chunk unloaded on a\n * normal page load, and a production build dead-code-eliminates it. The\n * build-time gate is {@link hasDebugConsole} at the call site: when the package\n * is absent this snippet is never produced, so the attach code cannot be in the\n * bundle at all.\n */\nexport function buildInAppSnippet(): string {\n return [\n '// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요',\n \"if (typeof window !== 'undefined') {\",\n ' const __ait_p = new URLSearchParams(window.location.search);',\n \" if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {\",\n ` void import('${DEBUG_CONSOLE_ID}').then((m) => m.maybeAttach());`,\n ' }',\n '}',\n ].join('\\n');\n}\n","/**\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';\nimport { DEBUGGER_DEV_BRIDGE_ID } from './optional-peers.js';\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 * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\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 `opts.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 *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\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, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\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/** 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 * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3 (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) the debug daemon's `start_attach` 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 * DELEGATED (issue #817): the implementation now lives in `@ait-co/debugger`'s\n * `/dev-bridge` subpath — the one cross-repo code delegation of the package\n * split. The dashboard needs the daemon's QR HTTP server, deep-link builder and\n * TOTP minting, all of which moved into that package; keeping a second copy here\n * would fork them. `@ait-co/debugger` is an OPTIONAL peer, so a consumer who\n * never asks for env-2 CDP installs nothing extra.\n *\n * Why this is not a separate plugin: the relay `wss://` URL must exist BEFORE\n * the QR banner is printed (the launcher QR carries `&relay=`), so the relay and\n * the dashboard are order-dependent and stay one composed call in the dev loop.\n *\n * Degradation: when `@ait-co/debugger` is not installed the import fails and we\n * return `undefined` — exactly the same \"no dashboard\" outcome as a closed GUI\n * gate, with the terminal ASCII QR standing alone. We do NOT fall back to a\n * local copy of the daemon code. The caller (`./index.ts`) prints the install\n * hint once when CDP was requested without the package.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs — neither here nor in the\n * delegate. The only thing opened/logged is `http://127.0.0.1:<port>` (local).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (package absent, no relay,\n * `qr:false`, headless, opt-out, or a start failure) — in which case the ASCII\n * QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to). Checked here\n // as well as in the delegate so an absent relay never even probes the package.\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 let devBridge: { startTunnelDashboard: typeof startTunnelDashboard };\n try {\n devBridge = (await import(DEBUGGER_DEV_BRIDGE_ID)) as {\n startTunnelDashboard: typeof startTunnelDashboard;\n };\n } catch {\n // Not installed (or an unexpected load failure) — degrade to ASCII QR only.\n // SECRET-HANDLING: the error is not surfaced (it can embed local paths).\n return undefined;\n }\n return devBridge.startTunnelDashboard(opts);\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":"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"}