@ait-co/devtools 0.2.0 → 0.2.2

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.
@@ -281,7 +281,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
281
281
  navBarTransparent,
282
282
  navBarTheme
283
283
  });
284
- const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-CPZAn-T5.js");
284
+ const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-dkII-DHD.js");
285
285
  await writeRelayUrls({
286
286
  projectRoot: server.config.root,
287
287
  tunnelBaseUrl: t.url,
@@ -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';\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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"}
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 (that daemon-side module\n// now lives in @ait-co/debugger as packages/debugger/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 (that daemon-side\n// module now lives in @ait-co/debugger as packages/debugger/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는 이 패키지가 여는 producer 쪽이고, 읽는 consumer는 `@ait-co/debugger`의\n * MCP stdio 데몬(bin `debugger`, `--mode=dev`)이다 — 그쪽이 AI 에이전트에 mock state를\n * 노출한다. 데몬은 optional peer라 미설치여도 이 endpoint 자체는 정상 동작한다.\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/debug-console`)가 읽어 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/debug-console) 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, the @ait-co/debugger\n // MCP stdio daemon 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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ait-co/devtools",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Browser development tools for Apps in Toss mini-apps — mock SDK, floating devtools panel, and universal bundler plugin. devDependency only; contributes nothing to a production bundle",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1 +0,0 @@
1
- {"version":3,"file":"relay-url-store-CPZAn-T5.js","names":[],"sources":["../src/unplugin/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store — WRITE/DELETE half (#424, relocated #818).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start needs two ephemeral URLs that\n * change on every run: the CDP relay's https base and the app tunnel's https\n * base. Rather than making the developer copy-paste them into env vars each\n * time, the dev server writes them to `<project>/.ait_urls` and the MCP daemon\n * (`@ait-co/debugger`) discovers them from there.\n *\n * Why this module is here and not in `@ait-co/debugger`: the writer IS the vite\n * dev-server plugin. `@ait-co/debugger` owns the READ half only — its daemon\n * reads `.ait_urls` read-only and never writes it. Splitting the file that way\n * matches who is allowed to mutate it, and it keeps the write path available\n * when `@ait-co/debugger` is not installed at all. The file format is the\n * contract between the two packages; changing it requires changing both sides.\n *\n * Kept deliberately minimal compared with the pre-split `src/mcp/relay-url-store.ts`:\n * the read path (`readRelayUrls`) moved out with the daemon, so only\n * {@link writeRelayUrls} and {@link deleteRelayUrls} remain.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the tunnel host —\n * the same sensitivity class as the `.ait_relay` TOTP secret. The raw values,\n * partial values, and the resolved file path MUST NOT appear in any log, error\n * message, stdout, stderr, or assertion output here or at any call site. Only\n * boolean pass/fail signals are safe to surface. The file is written mode 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n/**\n * Walks upward from `start` and returns the nearest directory containing a\n * `package.json`. Falls back to `start` when none is found, so a write still\n * lands somewhere deterministic.\n *\n * The writer (this module) and the reader (`@ait-co/debugger`'s daemon) use the\n * SAME anchor rule, so URLs written by `pnpm dev` are the ones the daemon finds:\n * real mini-apps keep `vite.config.ts` and `package.json` in one directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon also reaches via its\n * per-session projectRoot.\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) return dir;\n const parent = dirname(dir);\n if (parent === dir) return start;\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL. Omit when the relay was not started.\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The CDP relay's LOCAL http base URL (`http://127.0.0.1:<relay-port>`), used\n * by the daemon to build the Chii inspector URL without a tunnel round-trip\n * (issue #530). Loopback only — no tunnel host, safe to surface.\n */\n relayLocalUrl?: string;\n /**\n * The app tunnel's https base URL. Omit when unavailable.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes the present URL keys to `<projectRoot>/.ait_urls` (mode 0600),\n * overwriting (`flag: 'w'`) because the URLs are ephemeral — a fresh URL\n * replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * only the dev server writes this file, so there is no race to guard, and the\n * value must be fresh on every cold-start.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const {\n projectRoot,\n relayBaseUrl,\n relayLocalUrl,\n tunnelBaseUrl,\n fs: fsDep,\n existsSync: existsSyncDep,\n } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent or blank.\n const payload: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') payload.relayBaseUrl = relayBaseUrl;\n if (typeof relayLocalUrl === 'string' && relayLocalUrl !== '') {\n payload.relayLocalUrl = relayLocalUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: the JSON content (which includes URL values) goes to the\n // file only — never to any log, stdout, or stderr.\n fs.writeFileSync(filePath, JSON.stringify(payload), { mode: 0o600, flag: 'w' });\n}\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present, swallowing every error so\n * cleanup always succeeds.\n *\n * Called from the unplugin's `cleanup()` on `httpServer 'close'` + signals. A\n * stale `.ait_urls` pointing at a dead tunnel would make the daemon attempt a\n * doomed attach on the next cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) fs.unlinkSync(filePath);\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAa,iBAAiB;;;;;;;;;;;;;AA0B9B,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CAAE,QAAO;EACpD,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAAK,QAAO;AAC3B,QAAM;;;;;;;;;;AAWV,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;AAuCzE,eAAsB,eAAe,MAAyC;CAC5E,MAAM,EACJ,aACA,cACA,eACA,eACA,IAAI,OACJ,YAAY,kBACV;CAEJ,MAAM,KAAsB,SAAU,MAAM,OAAO;CAGnD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;CAGxD,MAAM,UAAqF,EAAE;AAC7F,KAAI,OAAO,iBAAiB,YAAY,iBAAiB,GAAI,SAAQ,eAAe;AACpF,KAAI,OAAO,kBAAkB,YAAY,kBAAkB,GACzD,SAAQ,gBAAgB;AAE1B,KAAI,OAAO,kBAAkB,YAAY,kBAAkB,GACzD,SAAQ,gBAAgB;AAK1B,IAAG,cAAc,UAAU,KAAK,UAAU,QAAQ,EAAE;EAAE,MAAM;EAAO,MAAM;EAAK,CAAC;;;;;;;;;;;;AAsBjF,eAAsB,gBAAgB,MAA0C;CAC9E,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB;CAE9D,MAAM,KAAuB,SAAU,MAAM,OAAO;CAGpD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI;AACF,MAAI,GAAG,WAAW,SAAS,CAAE,IAAG,WAAW,SAAS;SAC9C"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"relay-url-store-DLjlvMSA.cjs","names":[],"sources":["../src/unplugin/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store — WRITE/DELETE half (#424, relocated #818).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start needs two ephemeral URLs that\n * change on every run: the CDP relay's https base and the app tunnel's https\n * base. Rather than making the developer copy-paste them into env vars each\n * time, the dev server writes them to `<project>/.ait_urls` and the MCP daemon\n * (`@ait-co/debugger`) discovers them from there.\n *\n * Why this module is here and not in `@ait-co/debugger`: the writer IS the vite\n * dev-server plugin. `@ait-co/debugger` owns the READ half only — its daemon\n * reads `.ait_urls` read-only and never writes it. Splitting the file that way\n * matches who is allowed to mutate it, and it keeps the write path available\n * when `@ait-co/debugger` is not installed at all. The file format is the\n * contract between the two packages; changing it requires changing both sides.\n *\n * Kept deliberately minimal compared with the pre-split `src/mcp/relay-url-store.ts`:\n * the read path (`readRelayUrls`) moved out with the daemon, so only\n * {@link writeRelayUrls} and {@link deleteRelayUrls} remain.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the tunnel host —\n * the same sensitivity class as the `.ait_relay` TOTP secret. The raw values,\n * partial values, and the resolved file path MUST NOT appear in any log, error\n * message, stdout, stderr, or assertion output here or at any call site. Only\n * boolean pass/fail signals are safe to surface. The file is written mode 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n/**\n * Walks upward from `start` and returns the nearest directory containing a\n * `package.json`. Falls back to `start` when none is found, so a write still\n * lands somewhere deterministic.\n *\n * The writer (this module) and the reader (`@ait-co/debugger`'s daemon) use the\n * SAME anchor rule, so URLs written by `pnpm dev` are the ones the daemon finds:\n * real mini-apps keep `vite.config.ts` and `package.json` in one directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon also reaches via its\n * per-session projectRoot.\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) return dir;\n const parent = dirname(dir);\n if (parent === dir) return start;\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL. Omit when the relay was not started.\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The CDP relay's LOCAL http base URL (`http://127.0.0.1:<relay-port>`), used\n * by the daemon to build the Chii inspector URL without a tunnel round-trip\n * (issue #530). Loopback only — no tunnel host, safe to surface.\n */\n relayLocalUrl?: string;\n /**\n * The app tunnel's https base URL. Omit when unavailable.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes the present URL keys to `<projectRoot>/.ait_urls` (mode 0600),\n * overwriting (`flag: 'w'`) because the URLs are ephemeral — a fresh URL\n * replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * only the dev server writes this file, so there is no race to guard, and the\n * value must be fresh on every cold-start.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const {\n projectRoot,\n relayBaseUrl,\n relayLocalUrl,\n tunnelBaseUrl,\n fs: fsDep,\n existsSync: existsSyncDep,\n } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent or blank.\n const payload: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') payload.relayBaseUrl = relayBaseUrl;\n if (typeof relayLocalUrl === 'string' && relayLocalUrl !== '') {\n payload.relayLocalUrl = relayLocalUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: the JSON content (which includes URL values) goes to the\n // file only — never to any log, stdout, or stderr.\n fs.writeFileSync(filePath, JSON.stringify(payload), { mode: 0o600, flag: 'w' });\n}\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present, swallowing every error so\n * cleanup always succeeds.\n *\n * Called from the unplugin's `cleanup()` on `httpServer 'close'` + signals. A\n * stale `.ait_urls` pointing at a dead tunnel would make the daemon attempt a\n * doomed attach on the next cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) fs.unlinkSync(filePath);\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAa,iBAAiB;;;;;;;;;;;;;AA0B9B,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,cAAA,GAAA,UAAA,MAAkB,KAAK,eAAe,CAAC,CAAE,QAAO;EACpD,MAAM,UAAA,GAAA,UAAA,SAAiB,IAAI;AAC3B,MAAI,WAAW,IAAK,QAAO;AAC3B,QAAM;;;;;;;;;;AAWV,SAAgB,aAAa,OAAe,cAAiD;AAC3F,SAAA,GAAA,UAAA,MAAY,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;AAuCzE,eAAsB,eAAe,MAAyC;CAC5E,MAAM,EACJ,aACA,cACA,eACA,eACA,IAAI,OACJ,YAAY,kBACV;CAEJ,MAAM,KAAsB,SAAU,MAAM,OAAO;CAGnD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;CAGxD,MAAM,UAAqF,EAAE;AAC7F,KAAI,OAAO,iBAAiB,YAAY,iBAAiB,GAAI,SAAQ,eAAe;AACpF,KAAI,OAAO,kBAAkB,YAAY,kBAAkB,GACzD,SAAQ,gBAAgB;AAE1B,KAAI,OAAO,kBAAkB,YAAY,kBAAkB,GACzD,SAAQ,gBAAgB;AAK1B,IAAG,cAAc,UAAU,KAAK,UAAU,QAAQ,EAAE;EAAE,MAAM;EAAO,MAAM;EAAK,CAAC;;;;;;;;;;;;AAsBjF,eAAsB,gBAAgB,MAA0C;CAC9E,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB;CAE9D,MAAM,KAAuB,SAAU,MAAM,OAAO;CAGpD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI;AACF,MAAI,GAAG,WAAW,SAAS,CAAE,IAAG,WAAW,SAAS;SAC9C"}