@ait-co/devtools 0.1.44 → 0.1.46
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.
- package/README.en.md +16 -7
- package/README.md +34 -7
- package/dist/mcp/cli.d.ts +8 -1
- package/dist/mcp/cli.d.ts.map +1 -1
- package/dist/mcp/cli.js +709 -143
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +312 -15
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/package.json +1 -1
package/dist/mcp/cli.js
CHANGED
|
@@ -848,8 +848,75 @@ var AutoDevtoolsOpener = class {
|
|
|
848
848
|
}
|
|
849
849
|
};
|
|
850
850
|
//#endregion
|
|
851
|
+
//#region src/mcp/envelope.ts
|
|
852
|
+
/**
|
|
853
|
+
* Returns `true` when `AIT_MCP_COMPAT=chrome-devtools` is set, which bypasses
|
|
854
|
+
* envelope wrapping and returns raw payloads (0.1.x back-compat).
|
|
855
|
+
*/
|
|
856
|
+
function isCompatMode() {
|
|
857
|
+
return process.env.AIT_MCP_COMPAT === "chrome-devtools";
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Maps `McpEnvironment` to `EnvelopeEnv`. After #307 these are the same
|
|
861
|
+
* union (`mock | relay-dev | relay-live`), so this is identity — kept as a
|
|
862
|
+
* named export for surface stability if envelope env diverges in the future.
|
|
863
|
+
*/
|
|
864
|
+
function toEnvelopeEnv(env) {
|
|
865
|
+
return env;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Wraps `data` in a `ToolEnvelope<T>` **unless** compat mode is active, in
|
|
869
|
+
* which case `data` is returned as-is.
|
|
870
|
+
*
|
|
871
|
+
* Use this at every tool call-site in `debug-server.ts` and `server.ts`.
|
|
872
|
+
*
|
|
873
|
+
* @example
|
|
874
|
+
* ```ts
|
|
875
|
+
* return jsonResult(wrapEnvelope(listPages(connection, tunnel), {
|
|
876
|
+
* tool: 'list_pages',
|
|
877
|
+
* env: resolveEnvironment(),
|
|
878
|
+
* attached: connection.listTargets().length > 0,
|
|
879
|
+
* }));
|
|
880
|
+
* ```
|
|
881
|
+
*/
|
|
882
|
+
function wrapEnvelope(data, ctx) {
|
|
883
|
+
if (isCompatMode()) return data;
|
|
884
|
+
return {
|
|
885
|
+
ok: true,
|
|
886
|
+
data,
|
|
887
|
+
meta: {
|
|
888
|
+
tool: ctx.tool,
|
|
889
|
+
env: toEnvelopeEnv(ctx.env),
|
|
890
|
+
attached: ctx.attached,
|
|
891
|
+
contentType: ctx.contentType ?? "json"
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
851
896
|
//#region src/mcp/environment.ts
|
|
852
897
|
/**
|
|
898
|
+
* Returns `true` when the environment is any relay variant (`relay-dev` or
|
|
899
|
+
* `relay-live`). Use this instead of `env === 'relay'` for tier checks.
|
|
900
|
+
*/
|
|
901
|
+
function isRelayEnv(env) {
|
|
902
|
+
return env === "relay-dev" || env === "relay-live";
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Returns `true` when the environment is the LIVE relay (`relay-live`).
|
|
906
|
+
* This is the guard condition for side-effect tool protection.
|
|
907
|
+
*/
|
|
908
|
+
function isLiveRelayEnv(env) {
|
|
909
|
+
return env === "relay-live";
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Maps the new `McpEnvironment` union to the legacy two-value union
|
|
913
|
+
* (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
|
|
914
|
+
*/
|
|
915
|
+
function toLegacyEnv(env) {
|
|
916
|
+
if (env === "mock") return "mock";
|
|
917
|
+
return "relay";
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
853
920
|
* URL patterns that mark a CDP target as a real-device WebView relay.
|
|
854
921
|
*
|
|
855
922
|
* - `intoss-private://` is the Toss in-app private scheme — only ever observed
|
|
@@ -874,28 +941,43 @@ function isRelayUrl(url) {
|
|
|
874
941
|
* regardless of env vars or connection state. Cleared with `null`.
|
|
875
942
|
*/
|
|
876
943
|
let envOverride = null;
|
|
877
|
-
/**
|
|
944
|
+
/**
|
|
945
|
+
* Parses the `MCP_ENV` env var into a `McpEnvironment` if valid.
|
|
946
|
+
*
|
|
947
|
+
* Accepted values:
|
|
948
|
+
* - `mock` → `mock`
|
|
949
|
+
* - `relay-dev` → `relay-dev`
|
|
950
|
+
* - `relay-live` → `relay-live`
|
|
951
|
+
* - `relay` → `relay-dev` (backward-compat alias — resolves to relay-dev)
|
|
952
|
+
*
|
|
953
|
+
* Any other value is ignored and falls through to the next precedence step.
|
|
954
|
+
*/
|
|
878
955
|
function readEnvVar() {
|
|
879
956
|
const raw = process.env.MCP_ENV;
|
|
880
|
-
if (raw === "mock"
|
|
957
|
+
if (raw === "mock") return "mock";
|
|
958
|
+
if (raw === "relay-dev") return "relay-dev";
|
|
959
|
+
if (raw === "relay-live") return "relay-live";
|
|
960
|
+
if (raw === "relay") return "relay-dev";
|
|
881
961
|
}
|
|
882
962
|
/**
|
|
883
963
|
* Returns the current MCP environment, applying the precedence rules:
|
|
884
964
|
* 1. test override (if set)
|
|
885
965
|
* 2. `MCP_ENV` env var
|
|
886
|
-
* 3. CDP target URL pattern match
|
|
887
|
-
*
|
|
966
|
+
* 3. CDP target URL pattern match → `relay-dev` (conservative — LIVE
|
|
967
|
+
* requires explicit MCP_ENV=relay-live opt-in)
|
|
968
|
+
* 4. caller-stated `defaultEnv` (intent hint from the CLI mode)
|
|
969
|
+
* 5. baked-in default `mock`
|
|
888
970
|
*/
|
|
889
971
|
function getEnvironment(input = {}) {
|
|
890
972
|
if (envOverride !== null) return envOverride;
|
|
891
973
|
const fromEnv = readEnvVar();
|
|
892
974
|
if (fromEnv !== void 0) return fromEnv;
|
|
893
|
-
const { connection } = input;
|
|
975
|
+
const { connection, defaultEnv } = input;
|
|
894
976
|
if (connection !== void 0) {
|
|
895
977
|
const targets = connection.listTargets();
|
|
896
|
-
for (const t of targets) if (isRelayUrl(t.url)) return "relay";
|
|
978
|
+
for (const t of targets) if (isRelayUrl(t.url)) return "relay-dev";
|
|
897
979
|
}
|
|
898
|
-
return "mock";
|
|
980
|
+
return defaultEnv ?? "mock";
|
|
899
981
|
}
|
|
900
982
|
/**
|
|
901
983
|
* Returns the `EnvironmentReason` that drove the current `getEnvironment()`
|
|
@@ -904,15 +986,23 @@ function getEnvironment(input = {}) {
|
|
|
904
986
|
* secret value is ever returned.
|
|
905
987
|
*/
|
|
906
988
|
function getEnvironmentReason(input = {}) {
|
|
907
|
-
if (envOverride !== null)
|
|
989
|
+
if (envOverride !== null) {
|
|
990
|
+
if (envOverride === "mock") return "env-var-mock";
|
|
991
|
+
if (envOverride === "relay-live") return "env-var-relay-live";
|
|
992
|
+
return "env-var-relay-dev";
|
|
993
|
+
}
|
|
994
|
+
const rawVar = process.env.MCP_ENV;
|
|
908
995
|
const fromEnv = readEnvVar();
|
|
909
996
|
if (fromEnv === "mock") return "env-var-mock";
|
|
910
|
-
if (fromEnv === "relay") return "env-var-relay";
|
|
911
|
-
|
|
997
|
+
if (fromEnv === "relay-live") return "env-var-relay-live";
|
|
998
|
+
if (fromEnv === "relay-dev") return rawVar === "relay" ? "env-var-relay-compat" : "env-var-relay-dev";
|
|
999
|
+
const { connection, defaultEnv } = input;
|
|
912
1000
|
if (connection !== void 0) {
|
|
913
1001
|
const targets = connection.listTargets();
|
|
914
1002
|
for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
|
|
915
1003
|
}
|
|
1004
|
+
if (defaultEnv === "relay-live") return "default-relay-live";
|
|
1005
|
+
if (defaultEnv === "relay-dev") return "default-relay-dev";
|
|
916
1006
|
return "default-mock";
|
|
917
1007
|
}
|
|
918
1008
|
//#endregion
|
|
@@ -940,7 +1030,7 @@ function mcpError(message) {
|
|
|
940
1030
|
* @param reason - 환경이 결정된 근거 (EnvironmentReason 문자열).
|
|
941
1031
|
*/
|
|
942
1032
|
function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
|
|
943
|
-
return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "build_attach_url → QR 스캔으로 실기기를 attach하세요." : "MCP_ENV=mock
|
|
1033
|
+
return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 build_attach_url → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
|
|
944
1034
|
}
|
|
945
1035
|
/**
|
|
946
1036
|
* 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
|
|
@@ -956,7 +1046,7 @@ function tunnelDownError() {
|
|
|
956
1046
|
* enableDomains()가 "No mini-app page attached" 에러를 던질 때.
|
|
957
1047
|
*/
|
|
958
1048
|
function pageMissingError(toolName) {
|
|
959
|
-
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨.
|
|
1049
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dogfood 번들 배포 후 build_attach_url을 호출해 QR을 생성하세요: \`ait deploy --scheme-only\` → \`build_attach_url(scheme_url)\` → QR 스캔.`);
|
|
960
1050
|
}
|
|
961
1051
|
/**
|
|
962
1052
|
* 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
|
|
@@ -973,7 +1063,24 @@ function pageCrashError(toolName) {
|
|
|
973
1063
|
* call_sdk 호출 시 브리지가 없을 때.
|
|
974
1064
|
*/
|
|
975
1065
|
function sdkAbsentError(toolName) {
|
|
976
|
-
return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로
|
|
1066
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
|
|
1070
|
+
* 없이 호출했을 때 반환하는 거부 메시지.
|
|
1071
|
+
*
|
|
1072
|
+
* 다음 행동을 두 가지로 제시한다:
|
|
1073
|
+
* 1. 같은 호출에 `confirm: true` 인자를 추가해 재시도.
|
|
1074
|
+
* 2. 읽기 전용 환경(relay-dev, mock)으로 전환.
|
|
1075
|
+
*/
|
|
1076
|
+
function liveGuardError(toolName) {
|
|
1077
|
+
return mcpError(`[LIVE relay guard] ${toolName}은 현재 relay-live(실 출시 런타임) 세션에서 side-effect 호출입니다. 실유저에게 영향을 줄 수 있어 명시적 동의가 필요합니다.
|
|
1078
|
+
|
|
1079
|
+
다음 중 하나를 선택하세요:
|
|
1080
|
+
1. \`confirm: true\` 인자를 추가해 재호출: ${toolName}(…, confirm: true)\n 2. 읽기 전용 도구(list_pages, list_console_messages, take_screenshot 등)를 사용하세요.
|
|
1081
|
+
3. dogfood 빌드(relay-dev 환경)에서 먼저 검증 후 live에 적용하세요.
|
|
1082
|
+
|
|
1083
|
+
live-guard: MCP_ENV=relay-live + confirm: true missing`);
|
|
977
1084
|
}
|
|
978
1085
|
/**
|
|
979
1086
|
* relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
|
|
@@ -1541,7 +1648,9 @@ var ServerLockConflictError = class extends Error {
|
|
|
1541
1648
|
existingPid;
|
|
1542
1649
|
/** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
|
|
1543
1650
|
existingWssUrl;
|
|
1544
|
-
|
|
1651
|
+
/** ISO timestamp from the existing lock — when that session started. */
|
|
1652
|
+
existingStartedAt;
|
|
1653
|
+
constructor(existingPid, existingWssUrl, existingStartedAt) {
|
|
1545
1654
|
const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
|
|
1546
1655
|
super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
|
|
1547
1656
|
If it is already stopped but this error persists, remove the lock file:
|
|
@@ -1549,6 +1658,7 @@ If it is already stopped but this error persists, remove the lock file:
|
|
|
1549
1658
|
this.name = "ServerLockConflictError";
|
|
1550
1659
|
this.existingPid = existingPid;
|
|
1551
1660
|
this.existingWssUrl = existingWssUrl;
|
|
1661
|
+
this.existingStartedAt = existingStartedAt;
|
|
1552
1662
|
}
|
|
1553
1663
|
};
|
|
1554
1664
|
/** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
|
|
@@ -1601,6 +1711,29 @@ function removeLock(lockPath) {
|
|
|
1601
1711
|
} catch {}
|
|
1602
1712
|
}
|
|
1603
1713
|
/**
|
|
1714
|
+
* Sends SIGTERM to `pid` and waits up to `graceMs` (default 2 000 ms) for it
|
|
1715
|
+
* to exit; then falls back to SIGKILL. Synchronous — uses a busy-wait loop so
|
|
1716
|
+
* it is usable in the top-level startup path without async plumbing.
|
|
1717
|
+
*
|
|
1718
|
+
* Ignores errors from `process.kill` so that a race where the target exits
|
|
1719
|
+
* between the alive check and the kill call does not crash the caller.
|
|
1720
|
+
*/
|
|
1721
|
+
function killAndWait(pid, graceMs = 2e3) {
|
|
1722
|
+
try {
|
|
1723
|
+
process.kill(pid, "SIGTERM");
|
|
1724
|
+
} catch {
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const deadline = Date.now() + graceMs;
|
|
1728
|
+
while (isPidAlive(pid) && Date.now() < deadline) {
|
|
1729
|
+
const end = Date.now() + 100;
|
|
1730
|
+
while (Date.now() < end);
|
|
1731
|
+
}
|
|
1732
|
+
if (isPidAlive(pid)) try {
|
|
1733
|
+
process.kill(pid, "SIGKILL");
|
|
1734
|
+
} catch {}
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1604
1737
|
* Reads the current lock file without acquiring it. Returns the parsed
|
|
1605
1738
|
* `LockData` when the file exists and is valid, otherwise `null`. Used by
|
|
1606
1739
|
* `get_diagnostics` to surface the `serverLockHolder` field without
|
|
@@ -1614,18 +1747,28 @@ function readServerLock() {
|
|
|
1614
1747
|
*
|
|
1615
1748
|
* - If no lock exists (or the lock is stale): writes a new lock and returns a
|
|
1616
1749
|
* `LockHandle` with `updateWssUrl` + `release`.
|
|
1617
|
-
* - If a live process holds the lock
|
|
1750
|
+
* - If a live process holds the lock and `force` is `false` (default): writes
|
|
1751
|
+
* a clear recovery message to stderr and throws `ServerLockConflictError`.
|
|
1752
|
+
* - If a live process holds the lock and `force` is `true`: sends SIGTERM to
|
|
1753
|
+
* that process (waiting up to 2 s then SIGKILL) and takes over the lock.
|
|
1618
1754
|
*
|
|
1619
1755
|
* The initial `wssUrl` in the lock file is `null` — call
|
|
1620
1756
|
* `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
|
|
1621
1757
|
*/
|
|
1622
|
-
function acquireLock() {
|
|
1758
|
+
function acquireLock(options = {}) {
|
|
1759
|
+
const { force = false } = options;
|
|
1623
1760
|
const lockPath = lockFilePath();
|
|
1624
1761
|
const existing = readLock(lockPath);
|
|
1625
|
-
if (existing !== null) {
|
|
1626
|
-
|
|
1627
|
-
|
|
1762
|
+
if (existing !== null) if (isPidAlive(existing.pid)) if (force) {
|
|
1763
|
+
process.stderr.write(`[ait-debug] --force: terminating existing session PID=${existing.pid} …\n`);
|
|
1764
|
+
killAndWait(existing.pid);
|
|
1765
|
+
process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\n`);
|
|
1766
|
+
} else {
|
|
1767
|
+
const urlPart = existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : "wssUrl=(tunnel starting)";
|
|
1768
|
+
process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
|
|
1769
|
+
throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
|
|
1628
1770
|
}
|
|
1771
|
+
else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
|
|
1629
1772
|
const data = {
|
|
1630
1773
|
pid: process.pid,
|
|
1631
1774
|
wssUrl: null,
|
|
@@ -1915,6 +2058,83 @@ function warnPassthrough(name) {
|
|
|
1915
2058
|
}
|
|
1916
2059
|
SIGNATURES.map((s) => s.name);
|
|
1917
2060
|
//#endregion
|
|
2061
|
+
//#region src/mcp/totp.ts
|
|
2062
|
+
/**
|
|
2063
|
+
* RFC 6238 TOTP implementation (Node.js, node:crypto only).
|
|
2064
|
+
*
|
|
2065
|
+
* External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
|
|
2066
|
+
* to keep the dependency surface minimal. This hand-roll is ~30 lines and
|
|
2067
|
+
* covers exactly what relay-side auth needs.
|
|
2068
|
+
*
|
|
2069
|
+
* Algorithm summary (RFC 6238 + RFC 4226):
|
|
2070
|
+
* T = floor(now / 30) — 30-second time step counter
|
|
2071
|
+
* K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
|
|
2072
|
+
* MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
|
|
2073
|
+
* offset = MAC[19] & 0x0f
|
|
2074
|
+
* code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
|
|
2075
|
+
*
|
|
2076
|
+
* Security note (keep this comment accurate):
|
|
2077
|
+
* The baked-in secret in a dogfood build is extractable from the bundle by a
|
|
2078
|
+
* determined reverse engineer. This mechanism raises the bar from
|
|
2079
|
+
* "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
|
|
2080
|
+
* Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
|
|
2081
|
+
* blocked; deliberate reverse engineering is not. See threat model in
|
|
2082
|
+
* src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
|
|
2083
|
+
*
|
|
2084
|
+
* SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
|
|
2085
|
+
* log, error message, or string visible outside this module. Only boolean
|
|
2086
|
+
* pass/fail and reason enum values are safe to surface.
|
|
2087
|
+
*/
|
|
2088
|
+
/** Time step window in seconds (RFC 6238 default). */
|
|
2089
|
+
const TIME_STEP = 30;
|
|
2090
|
+
/** Number of digits in the generated code. */
|
|
2091
|
+
const DIGITS = 6;
|
|
2092
|
+
/**
|
|
2093
|
+
* Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
|
|
2094
|
+
* clock time.
|
|
2095
|
+
*
|
|
2096
|
+
* @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
|
|
2097
|
+
* bytes). Must be the output of `generateAttachToken()` or compatible.
|
|
2098
|
+
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
2099
|
+
* @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
|
|
2100
|
+
*/
|
|
2101
|
+
function generateTotp(secret, when = Date.now()) {
|
|
2102
|
+
const key = Buffer.from(secret, "hex");
|
|
2103
|
+
const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
|
|
2104
|
+
const counterBuf = Buffer.alloc(8);
|
|
2105
|
+
const hi = Math.floor(counter / 4294967296);
|
|
2106
|
+
const lo = counter >>> 0;
|
|
2107
|
+
counterBuf.writeUInt32BE(hi, 0);
|
|
2108
|
+
counterBuf.writeUInt32BE(lo, 4);
|
|
2109
|
+
const mac = createHmac("sha1", key).update(counterBuf).digest();
|
|
2110
|
+
const offset = mac[19] & 15;
|
|
2111
|
+
return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Verifies a TOTP code against the secret, accepting ±`skew` time steps to
|
|
2115
|
+
* tolerate clock drift between the relay host and the client device.
|
|
2116
|
+
*
|
|
2117
|
+
* Uses `timingSafeEqual` for constant-time comparison to prevent timing
|
|
2118
|
+
* side-channel attacks.
|
|
2119
|
+
*
|
|
2120
|
+
* @param secret - Hex-encoded shared secret.
|
|
2121
|
+
* @param code - The 6-digit code to verify (string or numeric).
|
|
2122
|
+
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
2123
|
+
* @param skew - Number of adjacent steps to accept on either side. Default 1
|
|
2124
|
+
* (accepts T-1, T, T+1 — a 90-second acceptance window).
|
|
2125
|
+
* @returns `true` if the code matches any accepted step, `false` otherwise.
|
|
2126
|
+
*/
|
|
2127
|
+
function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
2128
|
+
const normalised = String(code).padStart(DIGITS, "0");
|
|
2129
|
+
if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
|
|
2130
|
+
const candidateBuf = Buffer.from(normalised, "utf8");
|
|
2131
|
+
for (let delta = -skew; delta <= skew; delta++) {
|
|
2132
|
+
const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
|
|
2133
|
+
if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
|
|
2134
|
+
}
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
//#endregion
|
|
1918
2138
|
//#region src/mcp/tools.ts
|
|
1919
2139
|
/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
|
|
1920
2140
|
const DEBUG_TOOL_DEFINITIONS = [
|
|
@@ -1940,7 +2160,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1940
2160
|
},
|
|
1941
2161
|
{
|
|
1942
2162
|
name: "list_pages",
|
|
1943
|
-
description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
|
|
2163
|
+
description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network. When a page attaches or detaches the server emits notifications/tools/list_changed — call tools/list again to get the full updated tool surface.",
|
|
1944
2164
|
inputSchema: {
|
|
1945
2165
|
type: "object",
|
|
1946
2166
|
properties: {},
|
|
@@ -1950,7 +2170,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1950
2170
|
},
|
|
1951
2171
|
{
|
|
1952
2172
|
name: "build_attach_url",
|
|
1953
|
-
description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Requires the tunnel to be up — call list_pages first. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to
|
|
2173
|
+
description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Requires the tunnel to be up — call list_pages first. If the tunnel is not up, restart the MCP server: `npx @ait-co/devtools devtools-mcp`. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 30 s by default), then returns the attached page info too. On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). Requires MCP_ENV=relay-dev or relay-live (set automatically in debug-mode default).\n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
|
|
1954
2174
|
inputSchema: {
|
|
1955
2175
|
type: "object",
|
|
1956
2176
|
properties: {
|
|
@@ -1960,7 +2180,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1960
2180
|
},
|
|
1961
2181
|
wait_for_attach: {
|
|
1962
2182
|
type: "boolean",
|
|
1963
|
-
description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, timeout
|
|
2183
|
+
description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, timeout 30 s). On attach, the response includes the attached page list. On timeout, call build_attach_url again to resume polling."
|
|
1964
2184
|
},
|
|
1965
2185
|
open_in_browser: {
|
|
1966
2186
|
type: "boolean",
|
|
@@ -1993,7 +2213,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1993
2213
|
},
|
|
1994
2214
|
{
|
|
1995
2215
|
name: "take_screenshot",
|
|
1996
|
-
description: "Captures a PNG screenshot of the attached mini-app page over CDP (Page.captureScreenshot) so the agent can see the phone screen directly. Read-only. Returns an image content block.",
|
|
2216
|
+
description: "Captures a PNG screenshot of the attached mini-app page over CDP (Page.captureScreenshot) so the agent can see the phone screen directly. Read-only. Returns an image content block — this is the only debug tool that returns an image; all other debug tools return text (JSON).",
|
|
1997
2217
|
inputSchema: {
|
|
1998
2218
|
type: "object",
|
|
1999
2219
|
properties: {},
|
|
@@ -2013,13 +2233,19 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2013
2233
|
},
|
|
2014
2234
|
{
|
|
2015
2235
|
name: "evaluate",
|
|
2016
|
-
description: "Evaluates an arbitrary JavaScript expression on the attached mini-app page via CDP Runtime.evaluate (returnByValue: true) and returns the result. NOT read-only — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires the relay to be attached — call list_pages first. Throws if the evaluation throws an exception on the page.",
|
|
2236
|
+
description: "Evaluates an arbitrary JavaScript expression on the attached mini-app page via CDP Runtime.evaluate (returnByValue: true) and returns the result. NOT read-only — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires the relay to be attached — call list_pages first. Throws if the evaluation throws an exception on the page.\n\nSECURITY: expression and result are not redacted — never include secrets or auth tokens in the expression.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the expression may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.",
|
|
2017
2237
|
inputSchema: {
|
|
2018
2238
|
type: "object",
|
|
2019
|
-
properties: {
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2239
|
+
properties: {
|
|
2240
|
+
expression: {
|
|
2241
|
+
type: "string",
|
|
2242
|
+
description: "JavaScript expression to evaluate in the page context."
|
|
2243
|
+
},
|
|
2244
|
+
confirm: {
|
|
2245
|
+
type: "boolean",
|
|
2246
|
+
description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this expression may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
|
|
2247
|
+
}
|
|
2248
|
+
},
|
|
2023
2249
|
required: ["expression"]
|
|
2024
2250
|
},
|
|
2025
2251
|
availableIn: "both"
|
|
@@ -2039,7 +2265,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2039
2265
|
},
|
|
2040
2266
|
{
|
|
2041
2267
|
name: "call_sdk",
|
|
2042
|
-
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle).\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
|
|
2268
|
+
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle) — redeploy via dogfood channel: `ait build && aitcc app deploy`.\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
|
|
2043
2269
|
inputSchema: {
|
|
2044
2270
|
type: "object",
|
|
2045
2271
|
properties: {
|
|
@@ -2051,6 +2277,10 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2051
2277
|
type: "array",
|
|
2052
2278
|
description: "Arguments to pass to the SDK method (optional, default []).",
|
|
2053
2279
|
items: {}
|
|
2280
|
+
},
|
|
2281
|
+
confirm: {
|
|
2282
|
+
type: "boolean",
|
|
2283
|
+
description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this SDK call may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
|
|
2054
2284
|
}
|
|
2055
2285
|
},
|
|
2056
2286
|
required: ["name"]
|
|
@@ -2089,7 +2319,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2089
2319
|
},
|
|
2090
2320
|
{
|
|
2091
2321
|
name: "get_diagnostics",
|
|
2092
|
-
description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (
|
|
2322
|
+
description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
|
|
2093
2323
|
inputSchema: {
|
|
2094
2324
|
type: "object",
|
|
2095
2325
|
properties: { recent_errors_limit: {
|
|
@@ -2118,20 +2348,26 @@ function getToolAvailability(name) {
|
|
|
2118
2348
|
* Returns true when the named tool is available in the given environment.
|
|
2119
2349
|
* Unknown tools return `false` — callers should reject them as unknown rather
|
|
2120
2350
|
* than as env-mismatched.
|
|
2351
|
+
*
|
|
2352
|
+
* Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'`
|
|
2353
|
+
* availability tier — `isRelayEnv()` is used for the check.
|
|
2121
2354
|
*/
|
|
2122
2355
|
function isToolAvailableIn(name, env) {
|
|
2123
2356
|
const availability = getToolAvailability(name);
|
|
2124
2357
|
if (availability === void 0) return false;
|
|
2125
2358
|
if (availability === "both") return true;
|
|
2359
|
+
if (availability === "relay") return isRelayEnv(env);
|
|
2126
2360
|
return availability === env;
|
|
2127
2361
|
}
|
|
2128
2362
|
/**
|
|
2129
2363
|
* Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
|
|
2130
2364
|
* matches the given env. Pure — preserves order; both Tier C ("both") and the
|
|
2131
2365
|
* matching single-env tier pass through.
|
|
2366
|
+
*
|
|
2367
|
+
* Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'` tier.
|
|
2132
2368
|
*/
|
|
2133
2369
|
function filterToolsByEnvironment(tools, env) {
|
|
2134
|
-
return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
|
|
2370
|
+
return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
|
|
2135
2371
|
}
|
|
2136
2372
|
/**
|
|
2137
2373
|
* Tool names that are available before any page attaches (bootstrap tier).
|
|
@@ -2250,20 +2486,50 @@ function listPages(connection, tunnel) {
|
|
|
2250
2486
|
* URL plus this session's live relay. Throws if the tunnel is not up yet (no
|
|
2251
2487
|
* relay URL to splice in) — the caller surfaces that as a tool error.
|
|
2252
2488
|
*
|
|
2489
|
+
* When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and
|
|
2490
|
+
* splices it as `at=<code>` into the attach URL. The code is valid for one
|
|
2491
|
+
* 30-second time step (±1 skew accepted by the relay, so the effective window
|
|
2492
|
+
* is up to 90 s). If the scan happens after `totp.expiresAt`, call
|
|
2493
|
+
* `build_attach_url` again to get a fresh code.
|
|
2494
|
+
*
|
|
2253
2495
|
* Also validates the scheme URL's authority. A suspicious authority (empty,
|
|
2254
2496
|
* "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
|
|
2255
2497
|
* the result so the caller can show a helpful hint without blocking the link
|
|
2256
2498
|
* generation (the warning is consistent with how other validation in
|
|
2257
2499
|
* `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
|
|
2258
2500
|
* the scheme authority which is in the caller's input, not ours to own).
|
|
2501
|
+
*
|
|
2502
|
+
* SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code
|
|
2503
|
+
* and must never appear in any log, error message, or output outside of the
|
|
2504
|
+
* spliced `at=` param in `attachUrl`.
|
|
2505
|
+
*
|
|
2506
|
+
* @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.
|
|
2507
|
+
* @param tunnel - Current tunnel status from the running debug server.
|
|
2508
|
+
* @param totpSecret - Optional hex-encoded TOTP secret (from
|
|
2509
|
+
* `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into
|
|
2510
|
+
* the attach URL as `at=<code>`.
|
|
2259
2511
|
*/
|
|
2260
|
-
function buildAttachUrl(schemeUrl, tunnel) {
|
|
2512
|
+
function buildAttachUrl(schemeUrl, tunnel, totpSecret) {
|
|
2261
2513
|
if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
|
|
2262
2514
|
const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
|
|
2515
|
+
let totpCode;
|
|
2516
|
+
let totpMeta;
|
|
2517
|
+
if (totpSecret !== void 0 && totpSecret !== "") {
|
|
2518
|
+
const now = Date.now();
|
|
2519
|
+
totpCode = generateTotp(totpSecret, now);
|
|
2520
|
+
const STEP_SECONDS = 30;
|
|
2521
|
+
const expiresAtMs = (Math.floor(now / 1e3 / STEP_SECONDS) + 1) * STEP_SECONDS * 1e3;
|
|
2522
|
+
totpMeta = {
|
|
2523
|
+
enabled: true,
|
|
2524
|
+
ttlSeconds: STEP_SECONDS,
|
|
2525
|
+
expiresAt: new Date(expiresAtMs).toISOString()
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2263
2528
|
return {
|
|
2264
|
-
attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
|
|
2529
|
+
attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),
|
|
2265
2530
|
relayUrl: tunnel.wssUrl,
|
|
2266
|
-
...authorityWarning !== void 0 ? { authorityWarning } : {}
|
|
2531
|
+
...authorityWarning !== void 0 ? { authorityWarning } : {},
|
|
2532
|
+
...totpMeta !== void 0 ? { totp: totpMeta } : {}
|
|
2267
2533
|
};
|
|
2268
2534
|
}
|
|
2269
2535
|
/**
|
|
@@ -2879,6 +3145,32 @@ function readDevtoolsVersion() {
|
|
|
2879
3145
|
}
|
|
2880
3146
|
}
|
|
2881
3147
|
/**
|
|
3148
|
+
* Derives the next recommended action from a completed diagnostics snapshot.
|
|
3149
|
+
*
|
|
3150
|
+
* Branch rules (evaluated in priority order):
|
|
3151
|
+
* 1. tunnel.up === false → restart
|
|
3152
|
+
* 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
|
|
3153
|
+
* 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
|
|
3154
|
+
* 4. otherwise → null (session looks healthy)
|
|
3155
|
+
*
|
|
3156
|
+
* Pure — does not throw; receives the final assembled snapshot fields.
|
|
3157
|
+
*/
|
|
3158
|
+
function computeNextRecommendedAction(tunnel, pages, env) {
|
|
3159
|
+
if (!tunnel.up) return {
|
|
3160
|
+
tool: "restart",
|
|
3161
|
+
reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
|
|
3162
|
+
};
|
|
3163
|
+
if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
|
|
3164
|
+
tool: "build_attach_url",
|
|
3165
|
+
reason: "tunnel ready, no pages attached — call build_attach_url to generate the attach QR"
|
|
3166
|
+
};
|
|
3167
|
+
if (pages !== null && pages.crashDetectedAt !== null) return {
|
|
3168
|
+
tool: "build_attach_url",
|
|
3169
|
+
reason: `page crashed at ${pages.crashDetectedAt} — call build_attach_url to re-attach`
|
|
3170
|
+
};
|
|
3171
|
+
return null;
|
|
3172
|
+
}
|
|
3173
|
+
/**
|
|
2882
3174
|
* Builds the `get_diagnostics` response. Pure — does not throw; missing data
|
|
2883
3175
|
* fields are `null`. Async because `readMcpSdkVersion` needs `import()`.
|
|
2884
3176
|
*
|
|
@@ -2909,6 +3201,7 @@ async function getDiagnostics(input) {
|
|
|
2909
3201
|
} catch {}
|
|
2910
3202
|
const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
|
|
2911
3203
|
const recentErrors = collector.getRecentErrors(limit);
|
|
3204
|
+
const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env);
|
|
2912
3205
|
return {
|
|
2913
3206
|
mcpVersion,
|
|
2914
3207
|
devtoolsVersion,
|
|
@@ -2918,90 +3211,16 @@ async function getDiagnostics(input) {
|
|
|
2918
3211
|
lastDetachAt: collector.getLastDetachAt(),
|
|
2919
3212
|
recentErrors,
|
|
2920
3213
|
environment: {
|
|
2921
|
-
env,
|
|
2922
|
-
|
|
3214
|
+
kind: env,
|
|
3215
|
+
env: toLegacyEnv(env),
|
|
3216
|
+
reason: envReason,
|
|
3217
|
+
liveGuardActive: isLiveRelayEnv(env)
|
|
2923
3218
|
},
|
|
2924
|
-
serverLockHolder
|
|
3219
|
+
serverLockHolder,
|
|
3220
|
+
nextRecommendedAction
|
|
2925
3221
|
};
|
|
2926
3222
|
}
|
|
2927
3223
|
//#endregion
|
|
2928
|
-
//#region src/mcp/totp.ts
|
|
2929
|
-
/**
|
|
2930
|
-
* RFC 6238 TOTP implementation (Node.js, node:crypto only).
|
|
2931
|
-
*
|
|
2932
|
-
* External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
|
|
2933
|
-
* to keep the dependency surface minimal. This hand-roll is ~30 lines and
|
|
2934
|
-
* covers exactly what relay-side auth needs.
|
|
2935
|
-
*
|
|
2936
|
-
* Algorithm summary (RFC 6238 + RFC 4226):
|
|
2937
|
-
* T = floor(now / 30) — 30-second time step counter
|
|
2938
|
-
* K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
|
|
2939
|
-
* MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
|
|
2940
|
-
* offset = MAC[19] & 0x0f
|
|
2941
|
-
* code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
|
|
2942
|
-
*
|
|
2943
|
-
* Security note (keep this comment accurate):
|
|
2944
|
-
* The baked-in secret in a dogfood build is extractable from the bundle by a
|
|
2945
|
-
* determined reverse engineer. This mechanism raises the bar from
|
|
2946
|
-
* "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
|
|
2947
|
-
* Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
|
|
2948
|
-
* blocked; deliberate reverse engineering is not. See threat model in
|
|
2949
|
-
* src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
|
|
2950
|
-
*
|
|
2951
|
-
* SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
|
|
2952
|
-
* log, error message, or string visible outside this module. Only boolean
|
|
2953
|
-
* pass/fail and reason enum values are safe to surface.
|
|
2954
|
-
*/
|
|
2955
|
-
/** Time step window in seconds (RFC 6238 default). */
|
|
2956
|
-
const TIME_STEP = 30;
|
|
2957
|
-
/** Number of digits in the generated code. */
|
|
2958
|
-
const DIGITS = 6;
|
|
2959
|
-
/**
|
|
2960
|
-
* Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
|
|
2961
|
-
* clock time.
|
|
2962
|
-
*
|
|
2963
|
-
* @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
|
|
2964
|
-
* bytes). Must be the output of `generateAttachToken()` or compatible.
|
|
2965
|
-
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
2966
|
-
* @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
|
|
2967
|
-
*/
|
|
2968
|
-
function generateTotp(secret, when = Date.now()) {
|
|
2969
|
-
const key = Buffer.from(secret, "hex");
|
|
2970
|
-
const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
|
|
2971
|
-
const counterBuf = Buffer.alloc(8);
|
|
2972
|
-
const hi = Math.floor(counter / 4294967296);
|
|
2973
|
-
const lo = counter >>> 0;
|
|
2974
|
-
counterBuf.writeUInt32BE(hi, 0);
|
|
2975
|
-
counterBuf.writeUInt32BE(lo, 4);
|
|
2976
|
-
const mac = createHmac("sha1", key).update(counterBuf).digest();
|
|
2977
|
-
const offset = mac[19] & 15;
|
|
2978
|
-
return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
|
|
2979
|
-
}
|
|
2980
|
-
/**
|
|
2981
|
-
* Verifies a TOTP code against the secret, accepting ±`skew` time steps to
|
|
2982
|
-
* tolerate clock drift between the relay host and the client device.
|
|
2983
|
-
*
|
|
2984
|
-
* Uses `timingSafeEqual` for constant-time comparison to prevent timing
|
|
2985
|
-
* side-channel attacks.
|
|
2986
|
-
*
|
|
2987
|
-
* @param secret - Hex-encoded shared secret.
|
|
2988
|
-
* @param code - The 6-digit code to verify (string or numeric).
|
|
2989
|
-
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
2990
|
-
* @param skew - Number of adjacent steps to accept on either side. Default 1
|
|
2991
|
-
* (accepts T-1, T, T+1 — a 90-second acceptance window).
|
|
2992
|
-
* @returns `true` if the code matches any accepted step, `false` otherwise.
|
|
2993
|
-
*/
|
|
2994
|
-
function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
2995
|
-
const normalised = String(code).padStart(DIGITS, "0");
|
|
2996
|
-
if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
|
|
2997
|
-
const candidateBuf = Buffer.from(normalised, "utf8");
|
|
2998
|
-
for (let delta = -skew; delta <= skew; delta++) {
|
|
2999
|
-
const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
|
|
3000
|
-
if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
|
|
3001
|
-
}
|
|
3002
|
-
return false;
|
|
3003
|
-
}
|
|
3004
|
-
//#endregion
|
|
3005
3224
|
//#region src/mcp/tunnel.ts
|
|
3006
3225
|
/**
|
|
3007
3226
|
* cloudflared quick tunnel + attach banner for the debug-mode MCP server.
|
|
@@ -3374,13 +3593,19 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
|
|
|
3374
3593
|
* naturally via `enableDomains`). The tier only controls visibility.
|
|
3375
3594
|
*/
|
|
3376
3595
|
function createDebugServer(deps) {
|
|
3377
|
-
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep } = deps;
|
|
3378
|
-
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
|
|
3379
|
-
|
|
3596
|
+
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv, totpSecret } = deps;
|
|
3597
|
+
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
|
|
3598
|
+
connection,
|
|
3599
|
+
defaultEnv
|
|
3600
|
+
}));
|
|
3601
|
+
const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({
|
|
3602
|
+
connection,
|
|
3603
|
+
defaultEnv
|
|
3604
|
+
}));
|
|
3380
3605
|
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
3381
3606
|
const server = new Server({
|
|
3382
3607
|
name: "ait-debug",
|
|
3383
|
-
version: "0.1.
|
|
3608
|
+
version: "0.1.46"
|
|
3384
3609
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
3385
3610
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
3386
3611
|
const env = resolveEnvironment();
|
|
@@ -3424,7 +3649,7 @@ function createDebugServer(deps) {
|
|
|
3424
3649
|
if (name === "get_diagnostics") try {
|
|
3425
3650
|
const rawLimit = request.params.arguments?.recent_errors_limit;
|
|
3426
3651
|
const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
|
|
3427
|
-
|
|
3652
|
+
const result = await getDiagnostics({
|
|
3428
3653
|
tunnel: getTunnelStatus(),
|
|
3429
3654
|
connection,
|
|
3430
3655
|
env: resolveEnvironment(),
|
|
@@ -3432,7 +3657,9 @@ function createDebugServer(deps) {
|
|
|
3432
3657
|
collector,
|
|
3433
3658
|
readLock: readServerLock,
|
|
3434
3659
|
recentErrorsLimit
|
|
3435
|
-
})
|
|
3660
|
+
});
|
|
3661
|
+
const attached = connection.listTargets().length > 0;
|
|
3662
|
+
return envelopeResult(result, name, resolveEnvironment(), attached);
|
|
3436
3663
|
} catch (err) {
|
|
3437
3664
|
return errorResult(err, name);
|
|
3438
3665
|
}
|
|
@@ -3459,7 +3686,7 @@ function createDebugServer(deps) {
|
|
|
3459
3686
|
return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
|
|
3460
3687
|
};
|
|
3461
3688
|
try {
|
|
3462
|
-
const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
3689
|
+
const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), totpSecret);
|
|
3463
3690
|
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
3464
3691
|
const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
|
|
3465
3692
|
const guiAvailable = canOpenBrowser();
|
|
@@ -3468,7 +3695,8 @@ function createDebugServer(deps) {
|
|
|
3468
3695
|
const qrHeadless = await renderQr(attachUrl);
|
|
3469
3696
|
const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
|
|
3470
3697
|
attachUrl,
|
|
3471
|
-
relayUrl
|
|
3698
|
+
relayUrl,
|
|
3699
|
+
...totp ? { totp } : {}
|
|
3472
3700
|
}, null, 2)}\n\n${qrHeadless}`;
|
|
3473
3701
|
if (!waitForAttach) return { content: [{
|
|
3474
3702
|
type: "text",
|
|
@@ -3504,7 +3732,8 @@ function createDebugServer(deps) {
|
|
|
3504
3732
|
};
|
|
3505
3733
|
const shortText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
3506
3734
|
relayUrl,
|
|
3507
|
-
openResult
|
|
3735
|
+
openResult,
|
|
3736
|
+
...totp ? { totp } : {}
|
|
3508
3737
|
}, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
|
|
3509
3738
|
if (!waitForAttach) return { content: [{
|
|
3510
3739
|
type: "text",
|
|
@@ -3543,7 +3772,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3543
3772
|
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
3544
3773
|
attachUrl,
|
|
3545
3774
|
relayUrl,
|
|
3546
|
-
openResult
|
|
3775
|
+
openResult,
|
|
3776
|
+
...totp ? { totp } : {}
|
|
3547
3777
|
}, null, 2)}\n\n${qr}`;
|
|
3548
3778
|
if (!waitForAttach) return { content: [{
|
|
3549
3779
|
type: "text",
|
|
@@ -3571,7 +3801,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3571
3801
|
const qr = await renderQr(attachUrl);
|
|
3572
3802
|
const baseText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
3573
3803
|
attachUrl,
|
|
3574
|
-
relayUrl
|
|
3804
|
+
relayUrl,
|
|
3805
|
+
...totp ? { totp } : {}
|
|
3575
3806
|
}, null, 2)}\n\n${qr}`;
|
|
3576
3807
|
if (!waitForAttach) return { content: [{
|
|
3577
3808
|
type: "text",
|
|
@@ -3606,7 +3837,9 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3606
3837
|
if (connection instanceof ChiiCdpConnection) try {
|
|
3607
3838
|
await connection.refreshTargets();
|
|
3608
3839
|
} catch {}
|
|
3609
|
-
|
|
3840
|
+
const pagesData = listPages(connection, getTunnelStatus());
|
|
3841
|
+
const attached = connection.listTargets().length > 0;
|
|
3842
|
+
return envelopeResult(pagesData, name, resolveEnvironment(), attached);
|
|
3610
3843
|
}
|
|
3611
3844
|
return classifyEnableDomainError(err, name);
|
|
3612
3845
|
}
|
|
@@ -3618,11 +3851,14 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3618
3851
|
return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
|
|
3619
3852
|
}
|
|
3620
3853
|
case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
|
|
3621
|
-
case "list_pages":
|
|
3854
|
+
case "list_pages": {
|
|
3622
3855
|
if (connection instanceof ChiiCdpConnection) try {
|
|
3623
3856
|
await connection.refreshTargets();
|
|
3624
3857
|
} catch {}
|
|
3625
|
-
|
|
3858
|
+
const listPagesData = listPages(connection, getTunnelStatus());
|
|
3859
|
+
const listPagesAttached = connection.listTargets().length > 0;
|
|
3860
|
+
return envelopeResult(listPagesData, name, resolveEnvironment(), listPagesAttached);
|
|
3861
|
+
}
|
|
3626
3862
|
case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
|
|
3627
3863
|
case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
|
|
3628
3864
|
case "take_screenshot": {
|
|
@@ -3633,19 +3869,27 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3633
3869
|
mimeType: shot.mimeType
|
|
3634
3870
|
}] };
|
|
3635
3871
|
}
|
|
3636
|
-
case "measure_safe_area":
|
|
3872
|
+
case "measure_safe_area": {
|
|
3873
|
+
const safeAreaData = await measureSafeArea(connection, resolveEnvironment());
|
|
3874
|
+
const safeAreaAttached = connection.listTargets().length > 0;
|
|
3875
|
+
return envelopeResult(safeAreaData, name, resolveEnvironment(), safeAreaAttached);
|
|
3876
|
+
}
|
|
3637
3877
|
case "evaluate": {
|
|
3638
3878
|
const expression = request.params.arguments?.expression;
|
|
3639
3879
|
if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
|
|
3880
|
+
if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
|
|
3640
3881
|
return jsonResult$1(await evaluate(connection, expression));
|
|
3641
3882
|
}
|
|
3642
3883
|
case "call_sdk": {
|
|
3643
3884
|
const sdkName = request.params.arguments?.name;
|
|
3644
3885
|
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
|
|
3645
3886
|
const rawArgs = request.params.arguments?.args;
|
|
3646
|
-
const
|
|
3887
|
+
const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
|
|
3888
|
+
if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
|
|
3889
|
+
const sdkResult = await callSdk(connection, sdkName, sdkArgs);
|
|
3647
3890
|
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
|
|
3648
|
-
|
|
3891
|
+
const callSdkAttached = connection.listTargets().length > 0;
|
|
3892
|
+
return envelopeResult(sdkResult, name, resolveEnvironment(), callSdkAttached);
|
|
3649
3893
|
}
|
|
3650
3894
|
default: return unknownTool(name);
|
|
3651
3895
|
}
|
|
@@ -3661,6 +3905,22 @@ function jsonResult$1(value) {
|
|
|
3661
3905
|
text: JSON.stringify(value, null, 2)
|
|
3662
3906
|
}] };
|
|
3663
3907
|
}
|
|
3908
|
+
/**
|
|
3909
|
+
* Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it
|
|
3910
|
+
* as a text content block. When `AIT_MCP_COMPAT=chrome-devtools` is set the
|
|
3911
|
+
* envelope is skipped and the raw value is returned — identical to `jsonResult`.
|
|
3912
|
+
*/
|
|
3913
|
+
function envelopeResult(value, tool, env, attached) {
|
|
3914
|
+
const wrapped = wrapEnvelope(value, {
|
|
3915
|
+
tool,
|
|
3916
|
+
env,
|
|
3917
|
+
attached
|
|
3918
|
+
});
|
|
3919
|
+
return { content: [{
|
|
3920
|
+
type: "text",
|
|
3921
|
+
text: JSON.stringify(wrapped, null, 2)
|
|
3922
|
+
}] };
|
|
3923
|
+
}
|
|
3664
3924
|
function unknownTool(name) {
|
|
3665
3925
|
return mcpError(`알 수 없는 tool: ${name}`);
|
|
3666
3926
|
}
|
|
@@ -3756,7 +4016,7 @@ function buildRelayVerifyAuth() {
|
|
|
3756
4016
|
* 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
|
|
3757
4017
|
*/
|
|
3758
4018
|
async function runDebugServer(options = {}) {
|
|
3759
|
-
const lockHandle = acquireLock();
|
|
4019
|
+
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
3760
4020
|
const relayPort = options.relayPort ?? 0;
|
|
3761
4021
|
const verifyAuth = buildRelayVerifyAuth();
|
|
3762
4022
|
const totpEnabled = verifyAuth !== void 0;
|
|
@@ -3821,7 +4081,9 @@ async function runDebugServer(options = {}) {
|
|
|
3821
4081
|
get qrHttpServer() {
|
|
3822
4082
|
return qrServer;
|
|
3823
4083
|
},
|
|
3824
|
-
diagnosticsCollector
|
|
4084
|
+
diagnosticsCollector,
|
|
4085
|
+
defaultEnv: "relay-dev",
|
|
4086
|
+
...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
|
|
3825
4087
|
});
|
|
3826
4088
|
const transport = new StdioServerTransport();
|
|
3827
4089
|
let closed = false;
|
|
@@ -3869,7 +4131,10 @@ async function runDebugServer(options = {}) {
|
|
|
3869
4131
|
await server.connect(transport);
|
|
3870
4132
|
attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
|
|
3871
4133
|
diagnosticsCollector.recordAttach();
|
|
3872
|
-
devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({
|
|
4134
|
+
devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({
|
|
4135
|
+
connection,
|
|
4136
|
+
defaultEnv: "relay-dev"
|
|
4137
|
+
}));
|
|
3873
4138
|
});
|
|
3874
4139
|
}
|
|
3875
4140
|
/**
|
|
@@ -3891,7 +4156,7 @@ async function runDebugServer(options = {}) {
|
|
|
3891
4156
|
* expected and noted in the PR as an explicit out-of-scope follow-up.
|
|
3892
4157
|
*/
|
|
3893
4158
|
async function runLocalDebugServer(options = {}) {
|
|
3894
|
-
const lockHandle = acquireLock();
|
|
4159
|
+
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
3895
4160
|
const chromium = await launchChromium({
|
|
3896
4161
|
port: options.cdpPort ?? 0,
|
|
3897
4162
|
devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
|
|
@@ -3906,7 +4171,8 @@ async function runLocalDebugServer(options = {}) {
|
|
|
3906
4171
|
const server = createDebugServer({
|
|
3907
4172
|
connection,
|
|
3908
4173
|
aitSource,
|
|
3909
|
-
getTunnelStatus: () => tunnelStatus
|
|
4174
|
+
getTunnelStatus: () => tunnelStatus,
|
|
4175
|
+
defaultEnv: "mock"
|
|
3910
4176
|
});
|
|
3911
4177
|
const transport = new StdioServerTransport();
|
|
3912
4178
|
let closed = false;
|
|
@@ -4011,6 +4277,27 @@ var HttpAitSource = class {
|
|
|
4011
4277
|
* (dev). `devtools_get_mock_state` (the original devtools#130 name) is kept as a
|
|
4012
4278
|
* backward-compatible alias of `AIT.getMockState`.
|
|
4013
4279
|
*
|
|
4280
|
+
* Issue #305 (M2-1) — dev/debug tool-surface unification:
|
|
4281
|
+
* dev-mode now also exposes `list_pages`, `get_diagnostics`, `measure_safe_area`,
|
|
4282
|
+
* and `call_sdk` so the docs/qa/scenarios.md acceptance sequence
|
|
4283
|
+
* `list_pages → measure_safe_area → call_sdk` works in dev mode without
|
|
4284
|
+
* "Unknown tool" failures.
|
|
4285
|
+
*
|
|
4286
|
+
* - `list_pages` — shim: returns the Vite dev URL as a single-entry array.
|
|
4287
|
+
* - `get_diagnostics` — dumps dev-mode server state (endpoint URL, last fetch
|
|
4288
|
+
* error, reachability, mode/environment metadata).
|
|
4289
|
+
* - `measure_safe_area`— reads safeAreaInsets from the mock state snapshot
|
|
4290
|
+
* (source: 'mock-vite').
|
|
4291
|
+
* - `call_sdk` — reads mock state and builds a mock-equivalent result
|
|
4292
|
+
* using window.__ait.state for supported methods; returns
|
|
4293
|
+
* an explicit tier-filter error for methods that require
|
|
4294
|
+
* a live CDP bridge.
|
|
4295
|
+
* - CDP-only tools (`evaluate`, `take_screenshot`, `get_dom_document`,
|
|
4296
|
+
* `take_snapshot`, `list_console_messages`,
|
|
4297
|
+
* `list_network_requests`, `list_exceptions`) — return an
|
|
4298
|
+
* explicit tier-filter error explaining that CDP is unavailable
|
|
4299
|
+
* in dev-mode and pointing to `--mode=local` or `--mode=debug`.
|
|
4300
|
+
*
|
|
4014
4301
|
* This module is reached via the `devtools-mcp --mode=dev` CLI entry (see
|
|
4015
4302
|
* `cli.ts`); the default (no flag) bin mode is the debug-mode CDP/Chii server.
|
|
4016
4303
|
*
|
|
@@ -4025,12 +4312,18 @@ var HttpAitSource = class {
|
|
|
4025
4312
|
* }
|
|
4026
4313
|
* }
|
|
4027
4314
|
*/
|
|
4315
|
+
/** Error message prefix for CDP-dependent tools called in dev-mode. */
|
|
4316
|
+
const CDP_UNAVAILABLE_IN_DEV_MODE = "dev-mode에서는 CDP 연결이 없어 이 도구를 사용할 수 없습니다. 실기기 또는 로컬 Chromium에 붙이려면 `devtools-mcp --mode=local` 또는 `devtools-mcp` (debug 모드 기본)로 전환하세요.";
|
|
4028
4317
|
/**
|
|
4029
4318
|
* Tool descriptors served by the dev-mode server.
|
|
4030
4319
|
*
|
|
4031
4320
|
* All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
|
|
4032
4321
|
* itself is the mock-side embodiment of those Tier C tools. `availableIn` is
|
|
4033
4322
|
* declared so the surface stays consistent with the debug-mode registry.
|
|
4323
|
+
*
|
|
4324
|
+
* Issue #305: CDP-only tools are also listed with explicit descriptions so
|
|
4325
|
+
* agents do not get "Unknown tool" failures — they get a clear tier-filter
|
|
4326
|
+
* error message instead.
|
|
4034
4327
|
*/
|
|
4035
4328
|
const DEV_TOOL_DEFINITIONS = [
|
|
4036
4329
|
{
|
|
@@ -4072,30 +4365,290 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
4072
4365
|
required: []
|
|
4073
4366
|
},
|
|
4074
4367
|
availableIn: "both"
|
|
4368
|
+
},
|
|
4369
|
+
{
|
|
4370
|
+
name: "list_pages",
|
|
4371
|
+
description: "dev-mode: returns the Vite dev server URL as a single-entry page list. No CDP relay is involved — `tunnel.up` is always false and `devMode: true` marks this as a shim result. Call this first to confirm the dev server is reachable. In debug mode (`devtools-mcp` / `--mode=local`) this returns real attached pages.",
|
|
4372
|
+
inputSchema: {
|
|
4373
|
+
type: "object",
|
|
4374
|
+
properties: {},
|
|
4375
|
+
required: []
|
|
4376
|
+
},
|
|
4377
|
+
availableIn: "both"
|
|
4378
|
+
},
|
|
4379
|
+
{
|
|
4380
|
+
name: "get_diagnostics",
|
|
4381
|
+
description: "dev-mode: returns server diagnostics — Vite endpoint URL, last fetch timestamp/error, mock state endpoint reachability, mode (\"dev\"), and environment metadata. Call this when the dev server connection is suspect. In debug mode this returns tunnel/relay/attach status instead.",
|
|
4382
|
+
inputSchema: {
|
|
4383
|
+
type: "object",
|
|
4384
|
+
properties: { recent_errors_limit: {
|
|
4385
|
+
type: "number",
|
|
4386
|
+
description: "Ignored in dev-mode (no error ring buffer). Present for schema parity."
|
|
4387
|
+
} },
|
|
4388
|
+
required: []
|
|
4389
|
+
},
|
|
4390
|
+
availableIn: "both"
|
|
4391
|
+
},
|
|
4392
|
+
{
|
|
4393
|
+
name: "measure_safe_area",
|
|
4394
|
+
description: "dev-mode: reads safe-area insets from the mock state snapshot via the Vite endpoint. Returns `{ source: \"mock-vite\", sdkInsets, sdkInsetsSource: \"window.__ait\", ... }`. Values reflect what the DevTools panel reports at the time of the last state push. In debug mode this runs a Runtime.evaluate CDP probe on the attached page.",
|
|
4395
|
+
inputSchema: {
|
|
4396
|
+
type: "object",
|
|
4397
|
+
properties: {},
|
|
4398
|
+
required: []
|
|
4399
|
+
},
|
|
4400
|
+
availableIn: "both"
|
|
4401
|
+
},
|
|
4402
|
+
{
|
|
4403
|
+
name: "call_sdk",
|
|
4404
|
+
description: "dev-mode: calls a mock SDK method via the Vite mock state endpoint. Supported methods read from window.__ait mock state (e.g. getOperationalEnvironment). Returns the same `{ok, value}` / `{ok, error}` envelope as debug mode. In debug mode this calls the real SDK via window.__sdkCall over CDP.",
|
|
4405
|
+
inputSchema: {
|
|
4406
|
+
type: "object",
|
|
4407
|
+
properties: {
|
|
4408
|
+
name: {
|
|
4409
|
+
type: "string",
|
|
4410
|
+
description: "Mock SDK method name to call (e.g. \"getOperationalEnvironment\")."
|
|
4411
|
+
},
|
|
4412
|
+
args: {
|
|
4413
|
+
type: "array",
|
|
4414
|
+
description: "Arguments (ignored in dev-mode mock path; present for schema parity).",
|
|
4415
|
+
items: {}
|
|
4416
|
+
}
|
|
4417
|
+
},
|
|
4418
|
+
required: ["name"]
|
|
4419
|
+
},
|
|
4420
|
+
availableIn: "both"
|
|
4421
|
+
},
|
|
4422
|
+
{
|
|
4423
|
+
name: "evaluate",
|
|
4424
|
+
description: "Evaluates an arbitrary JavaScript expression via CDP Runtime.evaluate. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug` for CDP access.",
|
|
4425
|
+
inputSchema: {
|
|
4426
|
+
type: "object",
|
|
4427
|
+
properties: { expression: {
|
|
4428
|
+
type: "string",
|
|
4429
|
+
description: "JavaScript expression to evaluate."
|
|
4430
|
+
} },
|
|
4431
|
+
required: ["expression"]
|
|
4432
|
+
},
|
|
4433
|
+
availableIn: "both"
|
|
4434
|
+
},
|
|
4435
|
+
{
|
|
4436
|
+
name: "take_screenshot",
|
|
4437
|
+
description: "Captures a PNG screenshot via CDP Page.captureScreenshot. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4438
|
+
inputSchema: {
|
|
4439
|
+
type: "object",
|
|
4440
|
+
properties: {},
|
|
4441
|
+
required: []
|
|
4442
|
+
},
|
|
4443
|
+
availableIn: "both"
|
|
4444
|
+
},
|
|
4445
|
+
{
|
|
4446
|
+
name: "get_dom_document",
|
|
4447
|
+
description: "Returns the DOM tree via CDP DOM.getDocument. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4448
|
+
inputSchema: {
|
|
4449
|
+
type: "object",
|
|
4450
|
+
properties: {},
|
|
4451
|
+
required: []
|
|
4452
|
+
},
|
|
4453
|
+
availableIn: "both"
|
|
4454
|
+
},
|
|
4455
|
+
{
|
|
4456
|
+
name: "take_snapshot",
|
|
4457
|
+
description: "Captures a serialized page snapshot via CDP DOMSnapshot.captureSnapshot. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4458
|
+
inputSchema: {
|
|
4459
|
+
type: "object",
|
|
4460
|
+
properties: {},
|
|
4461
|
+
required: []
|
|
4462
|
+
},
|
|
4463
|
+
availableIn: "both"
|
|
4464
|
+
},
|
|
4465
|
+
{
|
|
4466
|
+
name: "list_console_messages",
|
|
4467
|
+
description: "Lists console messages captured via CDP Runtime.consoleAPICalled. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4468
|
+
inputSchema: {
|
|
4469
|
+
type: "object",
|
|
4470
|
+
properties: {},
|
|
4471
|
+
required: []
|
|
4472
|
+
},
|
|
4473
|
+
availableIn: "both"
|
|
4474
|
+
},
|
|
4475
|
+
{
|
|
4476
|
+
name: "list_network_requests",
|
|
4477
|
+
description: "Lists network requests captured via CDP Network events. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4478
|
+
inputSchema: {
|
|
4479
|
+
type: "object",
|
|
4480
|
+
properties: {},
|
|
4481
|
+
required: []
|
|
4482
|
+
},
|
|
4483
|
+
availableIn: "both"
|
|
4484
|
+
},
|
|
4485
|
+
{
|
|
4486
|
+
name: "list_exceptions",
|
|
4487
|
+
description: "Lists JS exceptions captured via CDP Runtime.exceptionThrown. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4488
|
+
inputSchema: {
|
|
4489
|
+
type: "object",
|
|
4490
|
+
properties: { limit: {
|
|
4491
|
+
type: "number",
|
|
4492
|
+
description: "Maximum exceptions to return."
|
|
4493
|
+
} },
|
|
4494
|
+
required: []
|
|
4495
|
+
},
|
|
4496
|
+
availableIn: "both"
|
|
4075
4497
|
}
|
|
4076
4498
|
];
|
|
4499
|
+
/** All tool names served in dev-mode (including tier-filter stubs). */
|
|
4077
4500
|
const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
|
|
4501
|
+
/** CDP-only tools — return a tier-filter error in dev-mode. */
|
|
4502
|
+
const CDP_ONLY_TOOL_NAMES = new Set([
|
|
4503
|
+
"evaluate",
|
|
4504
|
+
"take_screenshot",
|
|
4505
|
+
"get_dom_document",
|
|
4506
|
+
"take_snapshot",
|
|
4507
|
+
"list_console_messages",
|
|
4508
|
+
"list_network_requests",
|
|
4509
|
+
"list_exceptions"
|
|
4510
|
+
]);
|
|
4511
|
+
/**
|
|
4512
|
+
* Builds the `list_pages` dev-mode shim response.
|
|
4513
|
+
* Returns the Vite dev URL as a single-entry page list with `devMode: true`.
|
|
4514
|
+
*/
|
|
4515
|
+
function buildDevListPagesResult(devtoolsUrl) {
|
|
4516
|
+
return {
|
|
4517
|
+
pages: [{
|
|
4518
|
+
url: devtoolsUrl,
|
|
4519
|
+
title: "dev fixture",
|
|
4520
|
+
attached: true
|
|
4521
|
+
}],
|
|
4522
|
+
tunnel: { up: false },
|
|
4523
|
+
devMode: true,
|
|
4524
|
+
singleAttachModel: true
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
/**
|
|
4528
|
+
* Builds the `get_diagnostics` dev-mode response.
|
|
4529
|
+
* Probes the mock state endpoint reachability and returns server metadata.
|
|
4530
|
+
*/
|
|
4531
|
+
async function buildDevDiagnostics(devtoolsUrl, stateEndpoint, fetchImpl) {
|
|
4532
|
+
let reachable = false;
|
|
4533
|
+
let lastFetchError = null;
|
|
4534
|
+
let lastFetchAt = null;
|
|
4535
|
+
try {
|
|
4536
|
+
const res = await fetchImpl(stateEndpoint);
|
|
4537
|
+
reachable = res.ok;
|
|
4538
|
+
lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4539
|
+
if (!res.ok) lastFetchError = `HTTP ${res.status} ${res.statusText}`;
|
|
4540
|
+
} catch (err) {
|
|
4541
|
+
lastFetchError = err instanceof Error ? err.message : String(err);
|
|
4542
|
+
lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4543
|
+
}
|
|
4544
|
+
return {
|
|
4545
|
+
mode: "dev",
|
|
4546
|
+
devtoolsUrl,
|
|
4547
|
+
mcpStateEndpoint: stateEndpoint,
|
|
4548
|
+
mockStateEndpointReachable: reachable,
|
|
4549
|
+
lastFetchAt,
|
|
4550
|
+
lastFetchError,
|
|
4551
|
+
environment: {
|
|
4552
|
+
kind: "mock",
|
|
4553
|
+
reason: "dev-mode — Vite HTTP endpoint, no CDP connection"
|
|
4554
|
+
},
|
|
4555
|
+
nextRecommendedAction: reachable ? null : "mock state endpoint가 응답하지 않습니다. Vite dev 서버가 `mcp: true` 옵션으로 실행 중인지 확인하고, 필요하면 dev 서버를 재시작하세요."
|
|
4556
|
+
};
|
|
4557
|
+
}
|
|
4558
|
+
/**
|
|
4559
|
+
* Builds the `measure_safe_area` dev-mode response from mock state.
|
|
4560
|
+
* Reads `safeAreaInsets` from the AIT mock state and returns a parity-schema
|
|
4561
|
+
* result with `source: 'mock-vite'`.
|
|
4562
|
+
*/
|
|
4563
|
+
async function buildDevMeasureSafeArea(aitSource) {
|
|
4564
|
+
const rawInsets = (await aitSource.get("AIT.getMockState")).safeAreaInsets;
|
|
4565
|
+
let sdkInsets = null;
|
|
4566
|
+
if (rawInsets !== null && typeof rawInsets === "object" && !Array.isArray(rawInsets)) {
|
|
4567
|
+
const r = rawInsets;
|
|
4568
|
+
sdkInsets = {
|
|
4569
|
+
top: typeof r.top === "number" ? r.top : 0,
|
|
4570
|
+
right: typeof r.right === "number" ? r.right : 0,
|
|
4571
|
+
bottom: typeof r.bottom === "number" ? r.bottom : 0,
|
|
4572
|
+
left: typeof r.left === "number" ? r.left : 0
|
|
4573
|
+
};
|
|
4574
|
+
}
|
|
4575
|
+
return {
|
|
4576
|
+
source: "mock-vite",
|
|
4577
|
+
cssEnv: {
|
|
4578
|
+
top: 0,
|
|
4579
|
+
right: 0,
|
|
4580
|
+
bottom: 0,
|
|
4581
|
+
left: 0
|
|
4582
|
+
},
|
|
4583
|
+
sdkInsets,
|
|
4584
|
+
sdkInsetsSource: sdkInsets !== null ? "window.__ait" : null,
|
|
4585
|
+
...sdkInsets === null ? { sdkInsetsError: "window.__ait.state.safeAreaInsets not found in mock state snapshot" } : {},
|
|
4586
|
+
innerWidth: null,
|
|
4587
|
+
innerHeight: null,
|
|
4588
|
+
devicePixelRatio: null,
|
|
4589
|
+
userAgent: null,
|
|
4590
|
+
navBarHeight: null,
|
|
4591
|
+
navBarHeightSource: "not-available-in-dev-mode"
|
|
4592
|
+
};
|
|
4593
|
+
}
|
|
4594
|
+
/**
|
|
4595
|
+
* Builds the `call_sdk` dev-mode response.
|
|
4596
|
+
*
|
|
4597
|
+
* Supported methods are served from the mock state snapshot. Unsupported
|
|
4598
|
+
* methods return `{ ok: false, error: 'dev-mode-unsupported: ...' }` so the
|
|
4599
|
+
* agent gets an informative message rather than a generic failure.
|
|
4600
|
+
*/
|
|
4601
|
+
async function buildDevCallSdk(methodName, aitSource) {
|
|
4602
|
+
switch (methodName) {
|
|
4603
|
+
case "getOperationalEnvironment": {
|
|
4604
|
+
const env = await aitSource.get("AIT.getOperationalEnvironment");
|
|
4605
|
+
return {
|
|
4606
|
+
ok: true,
|
|
4607
|
+
value: {
|
|
4608
|
+
environment: env.environment,
|
|
4609
|
+
sdkVersion: env.sdkVersion
|
|
4610
|
+
}
|
|
4611
|
+
};
|
|
4612
|
+
}
|
|
4613
|
+
default: return {
|
|
4614
|
+
ok: false,
|
|
4615
|
+
error: `dev-mode-unsupported: "${methodName}"은 dev-mode에서 직접 호출할 수 없습니다. CDP bridge(window.__sdkCall)가 없으므로 실제 SDK 호출은 \`--mode=local\` 또는 debug 모드에서만 가능합니다. 지원 메서드: getOperationalEnvironment (mock state에서 읽음).`
|
|
4616
|
+
};
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4078
4619
|
/** Builds the dev-mode MCP server (does not connect a transport). */
|
|
4079
4620
|
function createDevServer(deps = {}) {
|
|
4080
|
-
const
|
|
4621
|
+
const devtoolsUrl = process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
|
|
4622
|
+
const stateEndpoint = `${devtoolsUrl}/api/ait-devtools/state`;
|
|
4081
4623
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
4082
4624
|
const server = new Server({
|
|
4083
4625
|
name: "ait-devtools",
|
|
4084
|
-
version: "0.1.
|
|
4626
|
+
version: "0.1.46"
|
|
4085
4627
|
}, { capabilities: { tools: {} } });
|
|
4086
4628
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
4087
4629
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4088
4630
|
const name = request.params.name;
|
|
4089
4631
|
if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
|
|
4632
|
+
if (CDP_ONLY_TOOL_NAMES.has(name)) return mcpError(`${name}: ${CDP_UNAVAILABLE_IN_DEV_MODE}`);
|
|
4090
4633
|
try {
|
|
4091
4634
|
const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
|
|
4092
|
-
if (
|
|
4093
|
-
switch (effective) {
|
|
4635
|
+
if (isAitToolName(effective)) switch (effective) {
|
|
4094
4636
|
case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
|
|
4095
4637
|
case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
|
|
4096
4638
|
case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
|
|
4097
4639
|
default: return mcpError(`알 수 없는 tool: ${name}`);
|
|
4098
4640
|
}
|
|
4641
|
+
switch (name) {
|
|
4642
|
+
case "list_pages": return jsonResult(buildDevListPagesResult(devtoolsUrl));
|
|
4643
|
+
case "get_diagnostics": return jsonResult(await buildDevDiagnostics(devtoolsUrl, stateEndpoint, (url) => fetch(url)));
|
|
4644
|
+
case "measure_safe_area": return jsonResult(await buildDevMeasureSafeArea(aitSource));
|
|
4645
|
+
case "call_sdk": {
|
|
4646
|
+
const sdkName = request.params.arguments?.name;
|
|
4647
|
+
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 메서드 이름을 전달하세요.");
|
|
4648
|
+
return jsonResult(await buildDevCallSdk(sdkName, aitSource));
|
|
4649
|
+
}
|
|
4650
|
+
default: return mcpError(`알 수 없는 tool: ${name}`);
|
|
4651
|
+
}
|
|
4099
4652
|
} catch (err) {
|
|
4100
4653
|
return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
|
|
4101
4654
|
}
|
|
@@ -4135,6 +4688,15 @@ async function runDevServer() {
|
|
|
4135
4688
|
*
|
|
4136
4689
|
* Node-only stdio process.
|
|
4137
4690
|
*/
|
|
4691
|
+
/**
|
|
4692
|
+
* Returns `true` when `--force` or `--takeover` is present in argv.
|
|
4693
|
+
*
|
|
4694
|
+
* Both flags are accepted as aliases — `--force` is the short form listed in
|
|
4695
|
+
* the `--help` output; `--takeover` is a longer synonym.
|
|
4696
|
+
*/
|
|
4697
|
+
function parseForce(argv) {
|
|
4698
|
+
return argv.includes("--force") || argv.includes("--takeover");
|
|
4699
|
+
}
|
|
4138
4700
|
/** Parses `--mode=<value>` / `--mode <value>` from argv; default `debug`. */
|
|
4139
4701
|
function parseMode(argv) {
|
|
4140
4702
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -4182,8 +4744,12 @@ function normalizeTarget(value) {
|
|
|
4182
4744
|
async function main() {
|
|
4183
4745
|
const args = process.argv.slice(2);
|
|
4184
4746
|
if (parseMode(args) === "dev") await runDevServer();
|
|
4185
|
-
else
|
|
4186
|
-
|
|
4747
|
+
else {
|
|
4748
|
+
const target = parseTarget(args);
|
|
4749
|
+
const force = parseForce(args);
|
|
4750
|
+
if (target === "local") await runLocalDebugServer({ force });
|
|
4751
|
+
else await runDebugServer({ force });
|
|
4752
|
+
}
|
|
4187
4753
|
}
|
|
4188
4754
|
/**
|
|
4189
4755
|
* True when this file is the process entry (the bin), not an import.
|
|
@@ -4210,6 +4776,6 @@ if (isEntrypoint()) main().catch((err) => {
|
|
|
4210
4776
|
process.exitCode = 1;
|
|
4211
4777
|
});
|
|
4212
4778
|
//#endregion
|
|
4213
|
-
export { parseMode, parseTarget };
|
|
4779
|
+
export { parseForce, parseMode, parseTarget };
|
|
4214
4780
|
|
|
4215
4781
|
//# sourceMappingURL=cli.js.map
|