@design-twin/dtwin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
package/src/doctor.ts ADDED
@@ -0,0 +1,602 @@
1
+ // `dtwin doctor` — one command that says what is wrong with the setup, and what to do about it.
2
+ //
3
+ // Every failure this tool has looks the same from the outside: a pull that waits and times out. The
4
+ // causes are not the same at all — no token, a token the plugin doesn't have, a port held by a
5
+ // forgotten daemon or by the MCP server, a plugin that isn't running, a stale export. Each check here
6
+ // names ONE of those, marks it ✓ / ! / ✗, and gives the single next step.
7
+ //
8
+ // Rules it keeps:
9
+ // - NO side effects. It never mints or persists a token (tokenStore.status() resolves with
10
+ // persist:false), never writes a file, and leaves nothing listening. So it is safe to run at any
11
+ // moment, including while a daemon or the MCP server holds the port.
12
+ // - It never opens a WebSocket to a port someone else holds: the bridge registers every WS client as
13
+ // a PLUGIN client, so a probe would disturb routing for whoever owns that bridge (and a
14
+ // wrong-token probe spams its log). A held port is identified with a plain HTTP GET instead — a
15
+ // bridge answers 426 Upgrade Required.
16
+ // - A bridge is only created once the port is known to be valid and free: createBridge exits the
17
+ // process on EADDRINUSE, which is a finding for doctor to report, not a way for it to die. Its
18
+ // settings (port, token) come from settings.ts like every other entry's. server-core is imported
19
+ // LAZILY (probePlugin's `await import()`) so a doctor run that never probes does not load it. The one
20
+ // server-core helper doctor needs outside that probe, daemonRowStalenessNote, lives in staleness.ts.
21
+ //
22
+ // dtwin doctor # human report; exit 1 only if a check is ✗
23
+ // dtwin doctor --wait 30 # wait longer for the plugin to connect (default 10s)
24
+ // dtwin doctor --json # { ok, checks: [{ id, title, status, detail, next, skipped }] }
25
+
26
+ import fs from "node:fs";
27
+ import net from "node:net";
28
+ import os from "node:os";
29
+ import http from "node:http";
30
+ import path from "node:path";
31
+ import * as tokenStore from "./token-store.ts";
32
+ import type { TokenStatus } from "./token-store.ts";
33
+ import * as daemon from "./daemon.ts";
34
+ import type { DaemonStatusView } from "./daemon.ts";
35
+ import { readExportVerdict } from "./export-verdict.ts";
36
+ import type { ExportSource, ExportVerdict } from "./export-verdict.ts";
37
+ import { errMsg } from "./errmsg.ts";
38
+ import { ifDefined } from "./json-util.ts";
39
+ import * as LAYOUT from "./project-layout.ts";
40
+ import { isOurMcpEntry, mcpEntryProblems, PROFILES, readMcpJson } from "./init.ts";
41
+ import { daemonRowStalenessNote } from "./staleness.ts";
42
+ // The manifest's allowedDomains ports and the FIGMA_BRIDGE_PORT parse — ports.ts, the same list and
43
+ // parse settings.ts reads the port with. A bad value is a finding here (the very case this exists to diagnose).
44
+ import { ALLOWED_PORTS, parsePortEnv } from "./ports.ts";
45
+ import { dtwinPortWarning } from "./verbs.ts";
46
+ // The package's own package.json, imported rather than read by a path at run time (so it resolves in a
47
+ // compiled binary too): engines.bun is the Bun version this install needs.
48
+ import pkg from "../package.json" with { type: "json" };
49
+ import type { ClientRow, Bridge } from "./server-core.ts";
50
+ import { exitCli } from "./stdio.ts";
51
+ import { daemonAddressOf, readSettings, tokenSetting } from "./settings.ts";
52
+
53
+ /** Every check this report can carry, by id — what `--json` consumers key on. */
54
+ export type CheckId = "bun" | "token" | "daemon" | "port" | "plugin" | "project" | "profile" | "layout" | "export" | "map" | "mcp";
55
+
56
+ /** One line of the report. `next` is the single next step (absent, or undefined, when there is none). `skipped`: the
57
+ * check could not run (a warn), so nothing it would have verified was — the report is not ok beside one. */
58
+ export interface Check {
59
+ id: CheckId;
60
+ title: string;
61
+ status: "ok" | "warn" | "fail";
62
+ detail: string;
63
+ next?: string;
64
+ skipped?: true;
65
+ }
66
+
67
+ export interface Report {
68
+ ok: boolean;
69
+ checks: Check[];
70
+ }
71
+
72
+ /** A connected-client row as doctor reads it — from this process's own probe bridge, or relayed by a
73
+ * daemon (possibly an older one whose rows lack newer fields). */
74
+ type ClientRowLike = Partial<ClientRow>;
75
+
76
+ /** probePort()'s answer. */
77
+ export interface PortProbe {
78
+ free: boolean;
79
+ holder?: "websocket" | "http" | "unknown";
80
+ detail?: string;
81
+ }
82
+
83
+ /** probePlugin()'s answer, or `{ skipped }` when the probe could not run (see checkPlugin). */
84
+ export interface PluginProbe {
85
+ skipped?: string;
86
+ next?: string;
87
+ clients?: ClientRowLike[];
88
+ badToken?: { at: number; fingerprint: string | null } | null;
89
+ expected?: string | null;
90
+ error?: string;
91
+ }
92
+
93
+ const ok = (id: CheckId, title: string, detail: string): Check => ({ id, title, status: "ok", detail });
94
+ const warn = (id: CheckId, title: string, detail: string, next?: string): Check => ({ id, title, status: "warn", detail, ...ifDefined("next", next) });
95
+ const fail = (id: CheckId, title: string, detail: string, next?: string): Check => ({ id, title, status: "fail", detail, ...ifDefined("next", next) });
96
+
97
+ // ---------------------------------------------------------------- pure checks
98
+
99
+ // `version` is the running Bun (Bun.version — NOT process.version, which under Bun reports the Node.js
100
+ // release Bun is compatible with, e.g. v26.3.0, and says nothing about Bun itself). `range` is
101
+ // package.json's engines.bun, matched with Bun's own semver (Bun.semver.satisfies), so any range
102
+ // syntax the field can hold is understood. An absent or unreadable range is reported as unknown
103
+ // rather than guessed at — and "unreadable" is decided here, because Bun.semver.satisfies is lenient
104
+ // to a fault: a range with no version in it at all ("", "x", "not a range!") satisfies everything.
105
+ function checkBun(version: string, range: string | null | undefined): Check {
106
+ const v = String(version).replace(/^v/, "");
107
+ const unread = (): Check => warn("bun", "Bun", `${v} (could not read the required version from package.json)`);
108
+ if (!range || !/\d+\.\d+/.test(range)) return unread();
109
+ let satisfied: boolean;
110
+ try { satisfied = Bun.semver.satisfies(v, range); } catch { return unread(); }
111
+ if (satisfied) return ok("bun", "Bun", `${v} (needs ${range})`);
112
+ return fail("bun", "Bun", `${v} is too old — this package needs ${range}`, "upgrade the way it was installed: `bun upgrade` (the official installer), `brew upgrade bun` (Homebrew), `npm install -g bun@latest` (npm), `scoop update bun` (Scoop) — or reinstall from https://bun.sh/install");
113
+ }
114
+
115
+ // `s` is tokenStore.status(). `pluginCheck` is the ALREADY-COMPUTED plugin check
116
+ // from later in run() — passed in only when `s.shadowed` is true, so the shadowed-token note can say
117
+ // what actually happened instead of a blanket "make sure the plugin has it": a connection succeeding
118
+ // under the overriding token IS the evidence it was accepted; a connection that never happened means
119
+ // this could not be checked at all, which is a different, more honest thing to say than "make sure".
120
+ // `viaDaemon` matters because a connection routed THROUGH an already-running daemon proves nothing
121
+ // about THIS process's token: the daemon authenticated the plugin once, at its own start, and every
122
+ // later command's socket auth is skipped — a bogus FIGMA_BRIDGE_TOKEN still shows "connected" there
123
+ // (`list clients` works and `doctor` says ok:true with a bogus token).
124
+ // Only a connection THIS process itself negotiated (no daemon in front) is real evidence either way.
125
+ function checkToken(s: TokenStatus, pluginCheck?: Check, viaDaemon?: boolean): Check {
126
+ if (s.activeSource === "ephemeral") {
127
+ return warn("token", "Bridge token", `none saved yet (${s.path} does not exist)`, "run `dtwin init` — or any pull: the first bridge start creates it and prints it once to paste into the plugin");
128
+ }
129
+ if (s.shadowed) {
130
+ if (pluginCheck && pluginCheck.status === "ok" && !viaDaemon) {
131
+ return ok("token", "Bridge token", `FIGMA_BRIDGE_TOKEN (${s.fingerprint}) OVERRIDES the saved token in ${s.path} — the plugin connected using it, so the override is working as intended`);
132
+ }
133
+ if (pluginCheck && pluginCheck.status === "fail" && /different token/.test(pluginCheck.detail || "")) {
134
+ return fail("token", "Bridge token", `FIGMA_BRIDGE_TOKEN (${s.fingerprint}) OVERRIDES the saved token in ${s.path}, and the connected plugin rejected it`, "paste `dtwin --show-token`'s value into the plugin, or unset FIGMA_BRIDGE_TOKEN to go back to the saved token");
135
+ }
136
+ const why = viaDaemon
137
+ ? "a running daemon already holds an authenticated connection, so this override was not exercised by this check"
138
+ : pluginCheck && pluginCheck.detail ? `plugin: ${pluginCheck.detail}` : "no daemon/plugin reachable";
139
+ return warn("token", "Bridge token", `FIGMA_BRIDGE_TOKEN (${s.fingerprint}) OVERRIDES the saved token in ${s.path} — whether the plugin accepts it could not be checked (${why})`, "make sure the plugin has the env var's token — or unset FIGMA_BRIDGE_TOKEN to go back to the saved one");
140
+ }
141
+ if (s.activeSource === "env") return ok("token", "Bridge token", `from FIGMA_BRIDGE_TOKEN (fingerprint ${s.fingerprint})`);
142
+ if (s.loosePerms) return warn("token", "Bridge token", `saved in ${s.path} (fingerprint ${s.fingerprint}), but the file is readable by other users`, `chmod 600 ${s.path}`);
143
+ return ok("token", "Bridge token", `saved in ${s.path} (fingerprint ${s.fingerprint})`);
144
+ }
145
+
146
+ const fileNames = (clients: ClientRowLike[] | null | undefined) => (clients || []).map((c) => c.file || "(unidentified)").join(", ");
147
+
148
+ // One bridge serves several Figma files at once (one client per open plugin window). With more than
149
+ // one connected, resolveClient() refuses any plugin-reaching command that doesn't say WHICH file —
150
+ // correct, but reporting both connections as a plain ✓ would let the next command's refusal
151
+ // come as a surprise. A connected-and-ambiguous bridge is healthy AND needs a flag;
152
+ // say both.
153
+ //
154
+ // Also surfaces a stale plugin bundle here — the ONE place doctor already reports which
155
+ // files are connected, so a version mismatch is seen in the same breath rather than needing a second
156
+ // command. `pluginStale` is computed server-side (server-core.ts's `describe()`), so doctor doesn't
157
+ // duplicate the version-compare logic; it just relays whichever client(s) are behind.
158
+ function connectedDetail(clients: ClientRowLike[] | null | undefined, prefix: string): { detail: string; next?: string } {
159
+ // daemonRowStalenessNote comes from staleness.ts, NOT server-core: server-core is only loaded for the
160
+ // plugin probe (this file's header), and staleness.ts is a plain static import.
161
+ const detail = `${prefix}: ${fileNames(clients)}`;
162
+ const stale = (clients || []).map((c) => c.pluginStale).filter((n): n is string => Boolean(n));
163
+ // A row with no `pluginVersion` KEY at all (not merely a `null` value) means it came from a
164
+ // `dtwin serve` daemon whose own `describe()` predates plugin-version reporting — a SEPARATE
165
+ // staleness, distinct from "this Figma plugin's bundle is old": reloading the plugin will not fix it,
166
+ // restarting the daemon will. `list clients`/`whoami` name it per-row (figma-pull.ts); doctor folds
167
+ // it into this same summary line so it's never a silent gap here either.
168
+ const daemonStale = [...new Set((clients || []).map(daemonRowStalenessNote).filter((n): n is string => Boolean(n)))];
169
+ if ((clients || []).length < 2 && !stale.length && !daemonStale.length) return { detail };
170
+ const list = clients ?? [];
171
+ const bits: string[] = [];
172
+ if (list.length >= 2) bits.push(`${detail} — ${list.length} files, so commands must say which`);
173
+ const nexts: string[] = [];
174
+ if (list.length >= 2) nexts.push("add `--client <connId|fileKey|part of the file name>` to every command that reaches the plugin (`dtwin list clients` lists them; MCP: a `client` argument)");
175
+ if (stale.length) nexts.push(...new Set(stale));
176
+ if (daemonStale.length) nexts.push(...daemonStale);
177
+ return {
178
+ detail: bits[0] ?? detail,
179
+ ...(nexts.length ? { next: nexts.join("; ") } : {}),
180
+ };
181
+ }
182
+
183
+ // `st` is daemon.status(at), at the address the settings resolve to: null when none is running. Without a daemon,
184
+ // every `dtwin pull` starts its own throwaway bridge and waits out the plugin's full reconnect window — the difference between
185
+ // an 8s pull and a 300s+ timeout, and `--client c1` becomes reconnect-order
186
+ // roulette instead of a stable id across a daemon's lifetime. That is a real cost, not a
187
+ // cosmetic one, so a missing daemon is reported `warn`, never `ok` — "optional" undersold it.
188
+ // `st` is read as DaemonStatusView: a daemon from an OLDER bridge may omit fields, which print as "?".
189
+ // `refused` is daemon.sockProblem(at): the socket location is not private to this user (or its path is too long
190
+ // for the platform), so no daemon can be started or reached there — every command still works, one-shot. A fail,
191
+ // not a warn: it is someone else's directory (or a loosened one) in the place this user's daemon would listen.
192
+ // `tokenNote` is sharedTokenNote(): on Windows the daemon's pipe is guarded by the token alone, and the
193
+ // token is read from somewhere other users can usually read — the line is demoted to a warning saying so
194
+ // (a refused location stays the fail it is).
195
+ function checkDaemon(st: DaemonStatusView | null | undefined, port: number, refused?: string | null, tokenNote?: string | null): Check {
196
+ const base = daemonState(st, port, refused);
197
+ if (!tokenNote || base.status === "fail") return base;
198
+ return warn("daemon", "Daemon", `${base.detail} — but ${tokenNote}`,
199
+ "keep the token under your profile — the default, %APPDATA%\\design-twin\\bridge-token, is — by unsetting FIGMA_BRIDGE_TOKEN_FILE / DESIGNTWIN_CONFIG_DIR (or pointing them inside your profile), then `dtwin --rotate-token`");
200
+ }
201
+
202
+ // On Windows the daemon's pipe can be opened by other users (its default security descriptor), and what
203
+ // keeps them out is the handshake — so the pipe is exactly as private as the token FILE. %APPDATA% is per-user
204
+ // by ACL; a file elsewhere (a --token-file / FIGMA_BRIDGE_TOKEN_FILE, or DESIGNTWIN_CONFIG_DIR, pointed at a
205
+ // shared folder or a directory under C:\) usually inherits read access for every user. Judged by path, under the
206
+ // profile or not: Bun cannot read a Windows ACL. Null off Windows (the socket's directory is the check there),
207
+ // for a token from the environment (no file), and for a file inside the profile.
208
+ function sharedTokenNote(file: string | null, profile: string, platform: NodeJS.Platform = process.platform): string | null {
209
+ if (platform !== "win32" || !file || !profile) return null;
210
+ const rel = path.win32.relative(path.win32.resolve(profile).toLowerCase(), path.win32.resolve(file).toLowerCase());
211
+ if (rel !== "" && !rel.startsWith("..") && !path.win32.isAbsolute(rel)) return null;
212
+ return `the bridge token is read from ${file}, outside your user profile (${profile}), and on Windows the daemon's pipe is only as private as that file: folders outside the profile are usually readable by other users`;
213
+ }
214
+
215
+ function daemonState(st: DaemonStatusView | null | undefined, port: number, refused?: string | null): Check {
216
+ if (refused) {
217
+ return fail(
218
+ "daemon", "Daemon", refused,
219
+ "remove that directory (if another user owns it, ask them or an admin), or set XDG_RUNTIME_DIR or TMPDIR to a directory only you can access (mode 0700) — until then `dtwin serve` refuses to start and every command runs without a daemon"
220
+ );
221
+ }
222
+ if (!st) {
223
+ return warn(
224
+ "daemon", "Daemon",
225
+ `none running on port ${port} — every pull will start its own bridge and wait out the plugin's reconnect window (this is what turns a normal export into a 300s+ timeout, and makes --client c1 unstable across runs)`,
226
+ "run `dtwin serve` in a background terminal before pulling — it keeps one bridge alive so plugins stay connected between commands"
227
+ );
228
+ }
229
+ const who = st.pluginConnected ? `plugin connected: ${fileNames(st.clients)}` : "no plugin connected to it";
230
+ return ok("daemon", "Daemon", `running (pid ${st.pid ?? "?"}, port ${st.port ?? "?"}) — ${who}`);
231
+ }
232
+
233
+ // `raw` is FIGMA_BRIDGE_PORT. Returns { port } or { problem: <check> }.
234
+ function resolvePort(raw: string | undefined): { port: number; problem?: undefined } | { problem: Check; port?: undefined } {
235
+ const parsed = parsePortEnv(raw);
236
+ if (parsed.port !== undefined) return { port: parsed.port };
237
+ return { problem: fail("port", "Port", `FIGMA_BRIDGE_PORT=${parsed.invalid} is not one of ${ALLOWED_PORTS.join(", ")} — the Figma plugin may only open sockets to ports named in its manifest, so nothing could ever connect`, `unset FIGMA_BRIDGE_PORT, or set it to one of ${ALLOWED_PORTS.join(", ")}`) };
238
+ }
239
+
240
+ // DT_TEST_EPHEMERAL_PORT is the suites' seam (server-core.ts createBridge): at "1" every bridge this shell starts binds a
241
+ // random port, which the Figma plugin — limited to the manifest's three — can never reach. Left exported in a shell, it
242
+ // reads exactly like "the plugin never connects"; say so before that hunt starts. Any other value is ignored by the
243
+ // bridge, but it is still a test variable that has escaped into a real environment.
244
+ function ephemeralPortWarning(env: NodeJS.ProcessEnv): Check | null {
245
+ const v = env.DT_TEST_EPHEMERAL_PORT;
246
+ if (v === undefined || v === "") return null;
247
+ return warn("port", "Port", v === "1"
248
+ ? "DT_TEST_EPHEMERAL_PORT=1 is set — a test-only seam: every bridge started from this environment binds a random port the Figma plugin can never reach"
249
+ : `DT_TEST_EPHEMERAL_PORT=${JSON.stringify(v)} is set — a test-only variable (the bridge acts on it only at "1")`, "unset DT_TEST_EPHEMERAL_PORT");
250
+ }
251
+
252
+ // `probe` is probePort()'s result; `st` the daemon status for the same port; `envPort` the value of
253
+ // FIGMA_BRIDGE_PORT (a seam, so the wording is testable without touching process.env). The free line
254
+ // says how the port was chosen, because the override is otherwise undiscoverable.
255
+ function checkPort(port: number, probe: PortProbe, st: DaemonStatusView | null | undefined, envPort?: string | undefined): Check {
256
+ if (probe.free) {
257
+ const via = envPort ? `FIGMA_BRIDGE_PORT=${envPort}` : `default; FIGMA_BRIDGE_PORT selects ${ALLOWED_PORTS.filter((p) => p !== port).join("/")}`;
258
+ return ok("port", "Port", `${port} is free (${via})`);
259
+ }
260
+ if (st) return ok("port", "Port", `${port} is held by the dtwin daemon (pid ${st.pid ?? "?"}) — pulls route through it`);
261
+ const others = ALLOWED_PORTS.filter((p) => p !== port).join(" or ");
262
+ if (probe.holder === "websocket") {
263
+ return warn("port", "Port", `${port} is held by a WebSocket server that is not a dtwin daemon — most likely the Design Twin MCP server (Claude Code), or a pull still running`, `use the MCP tools (writeToDisk:true) while it runs, or stop it, or run this CLI on another port: FIGMA_BRIDGE_PORT=${others}`);
264
+ }
265
+ return fail("port", "Port", `${port} is held by another program (${probe.detail})`, `find it with \`lsof -i :${port}\` and stop it, or use FIGMA_BRIDGE_PORT=${others} — the plugin walks all three`);
266
+ }
267
+
268
+ // `r` is probePlugin()'s result, or { skipped } when it could not run.
269
+ function checkPlugin(r: PluginProbe, waitSec?: number): Check {
270
+ const title = "Figma plugin";
271
+ if (r.skipped) return { ...warn("plugin", title, `not checked — ${r.skipped}`, r.next), skipped: true };
272
+ if (r.clients && r.clients.length) {
273
+ const c = connectedDetail(r.clients, "connected");
274
+ // A stale plugin bundle IS a problem worth a warn, unlike the multi-client case (still `ok` — being
275
+ // connected to several files at once is healthy, it just needs `--client`). Only staleness demotes
276
+ // the status; an ambiguous-but-current multi-client connection stays `ok`.
277
+ const stale = r.clients.some((cl) => cl.pluginStale);
278
+ return { id: "plugin", title, status: stale ? "warn" as const : "ok" as const, detail: c.detail, ...(c.next ? { next: c.next } : {}) };
279
+ }
280
+ if (r.badToken) {
281
+ return fail("plugin", title, `a plugin IS running, but with a different token (fingerprint ${r.badToken.fingerprint || "none — its token field is empty"} vs expected ${r.expected})`, "run `dtwin --show-token`, paste it into the plugin's \"Bridge token\" field, Save");
282
+ }
283
+ if (r.error) return fail("plugin", title, `could not open a bridge to check: ${r.error}`);
284
+ return fail("plugin", title, `no plugin connected within ${waitSec}s`, "in Figma DESKTOP open the file and run Plugins → Development → Design Twin (keep its window open); `--wait 30` waits longer");
285
+ }
286
+
287
+ const ageStr = (ms: number) => { const h = ms / 3600000; return h < 1 ? `${Math.max(0, Math.round(ms / 60000))} min` : h < 48 ? `${h.toFixed(1)}h` : `${Math.round(h / 24)} days`; };
288
+
289
+ // One source of the export (export-verdict.ts), said: whose screens, the design system, and how old each is.
290
+ function sourcePart(src: ExportSource): string {
291
+ const age = src.ageMs !== undefined ? `${ageStr(src.ageMs)} ago`
292
+ : src.problem === "unparseable" ? `exportedAt '${src.exportedAt}' is not a parseable timestamp` : "no stamp";
293
+ const from = src.sourceFile !== null ? ` from '${src.sourceFile}'` : "";
294
+ switch (src.kind) {
295
+ case "screens": return src.sourceFile === null
296
+ ? `${src.count} screen(s) (source not recorded — pulled by an older bridge; re-pull to stamp it)`
297
+ : `${src.count} screen(s)${from} (newest ${age})`;
298
+ case "design-system": return `design system${from} (${age})`;
299
+ case "page-walk": return `page walk${from} (${age})`;
300
+ case "screen-doc": return `screen '${src.sourceFile ?? path.basename(src.file ?? "")}' (${age})`;
301
+ }
302
+ }
303
+
304
+ // The export lines: one summary (always the first "export" check), then one warn per file that is there but unusable.
305
+ // Every fact is the verdict's (export-verdict.ts) — which file each screen came from, the newest stamp, whether it is
306
+ // stale, why there is no age — so doctor and figma_status cannot disagree about the same export; this only words it.
307
+ function exportChecks(v: ExportVerdict, rel: string): Check[] {
308
+ if (!v.exported) return [warn("export", "Export", `nothing exported yet (no design-system.json, pages/index.json or screen JSON in ${rel}/)`, "dtwin list → dtwin pull --node <id> (or --page <name> / --design-system)")];
309
+ const out: Check[] = [];
310
+ let problems = v.problems.map((p) => `${p.file} ${p.error}`);
311
+ if (!v.sources.length) {
312
+ out.push(warn("export", "Export", problems[0] ?? "", "re-run the pull"));
313
+ problems = problems.slice(1);
314
+ } else if (v.unknownFreshness) {
315
+ out.push(warn("export", "Export", v.unknownFreshness, "re-run the pull"));
316
+ } else {
317
+ const detail = v.sources.map(sourcePart).join(" + ");
318
+ const mixed = v.mixed ? "the export mixes Figma files — /designtwin:audit-design's cross-file check says whether they are one system" : undefined;
319
+ if (v.stale) out.push(warn("export", "Export", `${detail} — the Figma file may have moved on`, ["re-run the pull before building from it", mixed].filter(Boolean).join("; ")));
320
+ else out.push({ ...ok("export", "Export", detail), ...ifDefined("next", mixed) });
321
+ }
322
+ for (const p of problems) out.push(warn("export", "Export", `${p} — left out of the export summary`, "re-run the pull that writes it (or fix the hand edit)"));
323
+ return out;
324
+ }
325
+
326
+ // The profile build-screen, audit-design and sync-design will use: design/target.json's `profile` (a bundled one, or a
327
+ // custom stack written in design/profile.md), plus design/profile.md read after a bundled profile as the project's own
328
+ // rules. An older project's root profiles/<profile>.md is read the same way.
329
+ export const OVERLAY_FILE = path.join(LAYOUT.DESIGN_DIR, "profile.md");
330
+ function checkProfile(cwd: string): Check {
331
+ let profile: string | null = null;
332
+ try {
333
+ const doc: unknown = JSON.parse(fs.readFileSync(path.join(cwd, LAYOUT.TARGET_FILE), "utf8"));
334
+ const p = doc && typeof doc === "object" ? (doc as { profile?: unknown }).profile : undefined;
335
+ if (typeof p === "string" && p.trim()) profile = p.trim();
336
+ } catch { /* unreadable or not JSON: no profile chosen */ }
337
+ const overlay = fs.existsSync(path.join(cwd, OVERLAY_FILE));
338
+ const legacy = profile !== null && fs.existsSync(path.join(cwd, "profiles", profile + ".md"));
339
+ const extra = [overlay ? "design/profile.md (the project's rules, read after it)" : "", legacy ? `profiles/${profile}.md (older location, read the same way)` : ""].filter(Boolean).join(" + ");
340
+ if (profile === null) return warn("profile", "Profile", "design/target.json names no profile yet", "build-screen asks on its first run, or set `profile` to one of " + PROFILES.join(", "));
341
+ if (PROFILES.includes(profile)) return ok("profile", "Profile", `${profile} (bundled)${extra ? " + " + extra : ""}`);
342
+ if (overlay || legacy) return ok("profile", "Profile", `${profile} (custom) — ${extra}`);
343
+ return warn("profile", "Profile", `${profile} is not a bundled profile, and design/profile.md does not exist`,
344
+ "write design/profile.md from the plugin's skills/build-screen/profiles/_template.md, or set `profile` to one of " + PROFILES.join(", "));
345
+ }
346
+
347
+ // Everything about the project in `cwd`. Several checks, none of them ✗: doctor is also run outside a
348
+ // project (to debug the connection), where all of this is legitimately absent.
349
+ function checkProject(cwd: string, now: number = Date.now()): Check[] {
350
+ const out: Check[] = [];
351
+ const designDir = path.join(cwd, LAYOUT.DESIGN_DIR);
352
+ if (!fs.existsSync(designDir)) {
353
+ out.push(warn("project", "Project", `no design/ in ${cwd}`, "run `dtwin init` in the root of the project you are building (skip this if you are only testing the connection)"));
354
+ } else {
355
+ const hasTarget = fs.existsSync(path.join(cwd, LAYOUT.TARGET_FILE));
356
+ out.push(hasTarget ? ok("project", "Project", "design/ and design/target.json present") : warn("project", "Project", "design/ present, but no design/target.json", "run `dtwin init` (it detects the stack), or build-screen will ask on first run"));
357
+ if (hasTarget) out.push(checkProfile(cwd));
358
+
359
+ // Which layout this project uses, said out loud. A project created before the export/ split keeps
360
+ // working, but "where do my files land" must never be a thing the user has to infer.
361
+ const ex = LAYOUT.findExportDir(cwd);
362
+ if (ex.layout === "legacy-flat") {
363
+ out.push(warn("layout", "Layout", "this project uses the older flat layout — exports live directly in design/, beside your hand-owned target.json / plan / audit",
364
+ "it keeps working as is. To adopt the current split, move the export into design/export/ (pages/, design-system/, assets/, variables.json, libraries/) so `rm -rf design/export` can never take your decisions with it"));
365
+ } else if (ex.layout === "export-subdir" && ex.parallelLegacy) {
366
+ // A stray `dtwin pull design ...` wrote a SECOND export tree directly under
367
+ // design/ (design/pages/, design/assets/, design/variables.json, design/design-system/) beside
368
+ // the real one at design/export/. Doctor reads only design/export/ below, so say so out loud —
369
+ // silently picking one is exactly the trap this project is in.
370
+ out.push(warn("layout", "Layout", `found BOTH layouts: reading ${ex.rel.split(path.sep).join("/")}/ (the current one), but design/pages/, design/assets/, design/variables.json and/or design/design-system/ ALSO exist beside it — a stray pull wrote a second, parallel export tree`,
371
+ "the two trees can disagree (e.g. two different variables.json). Move anything real out of the stray design/pages, design/assets, design/variables.json, design/design-system into design/export/, then remove them — never `dtwin pull design ...` (that outDir already contains export/); use `dtwin pull --node <id>` etc."));
372
+ }
373
+
374
+ out.push(...exportChecks(readExportVerdict(ex.dir, now), ex.rel));
375
+
376
+ const map = LAYOUT.findMapFile(cwd);
377
+ out.push(
378
+ map.missing
379
+ ? warn("map", "Component map", `no ${LAYOUT.MAP_FILE} — builds cannot reuse your existing components yet`, "scaffold one after a pull: /designtwin:build-screen walks you through it")
380
+ : map.legacy
381
+ ? warn("map", "Component map", "codeconnect.local.json is at the repo root (the older location)", `it still works. Moving it to ${LAYOUT.MAP_FILE} keeps every hand-owned file in one place`)
382
+ : ok("map", "Component map", `${LAYOUT.MAP_FILE} present`)
383
+ );
384
+ }
385
+
386
+ const mcpFile = path.join(cwd, ".mcp.json");
387
+ if (fs.existsSync(mcpFile)) {
388
+ let servers: Record<string, unknown> | null = null;
389
+ let raw: unknown;
390
+ try { raw = JSON.parse(fs.readFileSync(mcpFile, "utf8")) as unknown; } catch (e) { out.push(warn("mcp", "MCP registration", ".mcp.json is not valid JSON: " + errMsg(e), "fix it, then `dtwin init --mcp`")); }
391
+ if (raw !== undefined) {
392
+ const r = readMcpJson(raw);
393
+ if ("servers" in r) servers = r.servers;
394
+ else out.push(warn("mcp", "MCP registration", ".mcp.json: " + r.error, "fix it, then `dtwin init --mcp`"));
395
+ }
396
+ if (servers) {
397
+ const found = servers;
398
+ const mine = Object.keys(found).find((k) => isOurMcpEntry(found[k]));
399
+ // Registered, but it cannot start (init.ts mcpEntryProblems): Claude Code would show the server failed and offer no
400
+ // tools, with nothing here to say why. A ✗, not a note — and init --mcp is the repair (it rewrites that entry).
401
+ const broken = mine === undefined ? [] : mcpEntryProblems(found[mine], { cwd });
402
+ if (!mine) out.push(ok("mcp", "MCP registration", "not registered in .mcp.json (optional — `dtwin init --mcp` adds it)"));
403
+ else if (broken.length) out.push(fail("mcp", "MCP registration", `registered as "${mine}" in .mcp.json, but Claude Code cannot reliably start it as written: ${broken.join("; ")}`, "run `dtwin init --mcp` from the install you use — it rewrites that entry to this bun and its figma-mcp.ts — then restart Claude Code"));
404
+ else if (mine === "designtwin") out.push(ok("mcp", "MCP registration", 'registered as "designtwin" in .mcp.json'));
405
+ else out.push(warn("mcp", "MCP registration", `registered under the legacy key "${mine}" — it still works, but new setups use "designtwin" ("figma" collides with Figma's own MCP server)`, `rename the "${mine}" key in .mcp.json to "designtwin", then restart Claude Code`));
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+
411
+ // ---------------------------------------------------------------- probes (I/O)
412
+
413
+ // Is the port free, and if not, what kind of thing holds it? Binds exactly as the bridge does
414
+ // (127.0.0.1), then lets go at once. Never opens a WebSocket — see the header.
415
+ function probePort(port: number): Promise<PortProbe> {
416
+ return new Promise<PortProbe>((resolve) => {
417
+ const srv = net.createServer();
418
+ srv.once("error", (e: NodeJS.ErrnoException) => {
419
+ if (e.code !== "EADDRINUSE") return resolve({ free: false, holder: "unknown", detail: errMsg(e) });
420
+ const req = http.get({ host: "127.0.0.1", port, path: "/", timeout: 1500 }, (res) => {
421
+ res.resume();
422
+ resolve(res.statusCode === 426 ? { free: false, holder: "websocket" } : { free: false, holder: "http", detail: `an HTTP server, answered ${res.statusCode}` });
423
+ });
424
+ req.on("timeout", () => req.destroy(new Error("no HTTP answer")));
425
+ req.on("error", (err) => resolve({ free: false, holder: "unknown", detail: "not an HTTP server: " + errMsg(err) }));
426
+ });
427
+ srv.listen(port, "127.0.0.1", () => srv.close(() => resolve({ free: true })));
428
+ });
429
+ }
430
+
431
+ // Open a real bridge on the (free) port and see whether a plugin shows up. The plugin retries every
432
+ // second, one of three ports at a time (slower in a background window), hence the wait. The bridge's own
433
+ // stderr chatter is muted for the duration: doctor reports the same facts itself, in one place. ALWAYS closes.
434
+ async function probePlugin(port: number, waitMs: number): Promise<PluginProbe> {
435
+ const realError = console.error;
436
+ console.error = () => {};
437
+ let bridge: Bridge | null = null;
438
+ try {
439
+ const read = readSettings();
440
+ if (!read.ok) return { clients: [], error: read.problem };
441
+ const core = await import("./server-core.ts");
442
+ const b = core.createBridge({ ...read.settings, port });
443
+ bridge = b;
444
+ const start = Date.now();
445
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
446
+ // Only a refusal from THIS probe counts — the last bad token is kept process-wide.
447
+ const refused = () => { const bad = b.authStats().lastBadToken; return bad && bad.at >= start ? bad : null; };
448
+ while (Date.now() - start < waitMs && !b.isConnected() && !refused()) await sleep(200);
449
+ if (b.isConnected()) {
450
+ // The file name arrives in a hello just after the socket opens, and a second open file may be a
451
+ // redial behind: the bridge's own unaddressed wait (server-core awaitClient) covers both — the
452
+ // settle, then each socket's hello or its identification grace — so the files are listed by name.
453
+ // Not bounded by the wait: every window is stretched to the settle, so a probe whose plugin connects
454
+ // pays the settle (3.5 s) after it even with one file open; a window that leaves meanwhile restarts the
455
+ // settle, up to the bridge's cap. A diagnostic can afford that; naming a second open file is worth it.
456
+ await b.awaitClient(null, Math.max(1, waitMs - (Date.now() - start)));
457
+ return { clients: b.listClients() };
458
+ }
459
+ return { clients: [], badToken: refused(), expected: b.authStats().expected };
460
+ } catch (e) {
461
+ return { clients: [], error: errMsg(e) };
462
+ } finally {
463
+ if (bridge) bridge.close();
464
+ console.error = realError;
465
+ }
466
+ }
467
+
468
+ // ---------------------------------------------------------------- run + report
469
+
470
+ // `onCheck` sees each result the moment it is known, and `onWait` fires before the one slow step (the
471
+ // plugin wait). Printing only at the end meant a caller with a short timeout — an agent shelling out —
472
+ // got NOTHING, not even the instant Bun/token answers, if the plugin wait outlived it.
473
+ export interface RunOptions {
474
+ cwd?: string;
475
+ waitSec?: number;
476
+ onCheck?: (c: Check) => void;
477
+ onWait?: (sec: number) => void;
478
+ }
479
+
480
+ async function run({ cwd = process.cwd(), waitSec = 10, onCheck, onWait }: RunOptions = {}): Promise<Report> {
481
+ const checks: Check[] = [];
482
+ const add = (...cs: Check[]) => { for (const c of cs) { checks.push(c); if (onCheck) onCheck(c); } };
483
+ // A package.json without `engines.bun` reads as null — checkBun then reports the range as unreadable.
484
+ add(checkBun(Bun.version, (pkg as { engines?: { bun?: string } }).engines?.bun ?? null));
485
+
486
+ const tok = tokenStore.status();
487
+ // A shadowed token's real answer depends on the plugin check below — emit it
488
+ // once that is known, rather than a static "make sure" note before anything has actually been
489
+ // checked. Every other case is unaffected and keeps its original position, right after Bun.
490
+ if (!tok.shadowed) add(checkToken(tok));
491
+
492
+ // DTWIN_PORT is not read anywhere; a person who set it believes the bridge moved.
493
+ const portWarning = dtwinPortWarning(process.env);
494
+ if (portWarning) add(warn("port", "Port", portWarning));
495
+ const seam = ephemeralPortWarning(process.env);
496
+ if (seam) add(seam);
497
+ const { port, problem } = resolvePort(process.env.FIGMA_BRIDGE_PORT);
498
+ let pluginCheck: Check;
499
+ let viaDaemon = false;
500
+ if (problem) {
501
+ add(problem);
502
+ pluginCheck = checkPlugin({ skipped: "the port setting has to be fixed first" });
503
+ add(pluginCheck);
504
+ } else {
505
+ // The daemon is looked up where every other command looks it up: the address the settings resolve to — its
506
+ // socket, or on Windows the pipe named from the token as the settings have it. A refused socket location is
507
+ // reported, not read as "no daemon" (status() alone would only warn on stderr and return null).
508
+ const read = readSettings();
509
+ const at = read.ok ? daemonAddressOf(read.settings, port) : null;
510
+ const refused = at ? daemon.sockProblem(at) : null;
511
+ const st = refused || !at ? null : await daemon.status(at).catch(() => null);
512
+ // The file the effective token is read from (none for FIGMA_BRIDGE_TOKEN); a token file that cannot be read
513
+ // is still the one the setup points at.
514
+ const token = read.ok ? read.settings.token : tokenSetting();
515
+ const tokenFile = token.ok ? token.resolved.path : (process.env.FIGMA_BRIDGE_TOKEN_FILE || null);
516
+ add(checkDaemon(st, port, refused, sharedTokenNote(tokenFile, process.env.USERPROFILE || os.homedir())));
517
+ const probe = await probePort(port);
518
+ add(checkPort(port, probe, st, process.env.FIGMA_BRIDGE_PORT));
519
+
520
+ if (st) {
521
+ viaDaemon = true;
522
+ // The daemon owns the bridge, so its view IS the answer — no second bridge needed (or possible).
523
+ // Status is `ok` unless a connected client is running a stale plugin bundle — that
524
+ // demotion is independent of, and composes with, the shadowed-token classification below,
525
+ // which reads `pluginCheck.status` to decide whether the token warning is real.
526
+ pluginCheck = st.pluginConnected
527
+ ? ((): Check => {
528
+ // daemonRowStalenessNote: staleness.ts, not server-core — see connectedDetail's comment
529
+ const c = connectedDetail(st.clients, "connected (through the daemon)");
530
+ // Two independent kinds of stale, either one is a warn: a stale PLUGIN bundle (pluginStale,
531
+ // set by an up-to-date daemon that saw a version-less/old `hello`), or the DAEMON itself
532
+ // predating version reporting (its rows have no `pluginVersion` key at all — the live case
533
+ // this was found in: a long-running `dtwin serve` older than this bridge).
534
+ const stale = (st.clients ?? []).some((cl) => cl.pluginStale || daemonRowStalenessNote(cl));
535
+ return { id: "plugin", title: "Figma plugin", status: stale ? "warn" : "ok", detail: c.detail, ...(c.next ? { next: c.next } : {}) };
536
+ })()
537
+ : fail("plugin", "Figma plugin", "the daemon is running, but no plugin is connected to it", "in Figma DESKTOP open the file and run Plugins → Development → Design Twin; if its window says the token is wrong, re-paste `dtwin --show-token`");
538
+ add(pluginCheck);
539
+ } else if (!probe.free) {
540
+ pluginCheck = checkPlugin({ skipped: `port ${port} is held by something else, and probing it would disturb it`, ...(probe.holder === "websocket" ? { next: "if that is the MCP server, ask Claude Code to call figma_status — it reports the plugin connection" } : {}) });
541
+ add(pluginCheck);
542
+ } else if (tok.activeSource === "ephemeral") {
543
+ pluginCheck = checkPlugin({ skipped: "there is no token yet, and doctor never creates one", next: "run `dtwin init`, paste the token into the plugin, then run doctor again" });
544
+ add(pluginCheck);
545
+ } else {
546
+ if (onWait) onWait(waitSec);
547
+ pluginCheck = checkPlugin(await probePlugin(port, waitSec * 1000), waitSec);
548
+ add(pluginCheck);
549
+ }
550
+ }
551
+ if (tok.shadowed) add(checkToken(tok, pluginCheck, viaDaemon));
552
+
553
+ add(...checkProject(cwd));
554
+ return { ok: reportOk(checks), checks };
555
+ }
556
+
557
+ // A skipped check (the plugin probe could not run: a port held by something else, no token yet) is not advice like an
558
+ // ordinary warn — a check that was supposed to answer "can this project actually pull anything" never ran. `ok: true`
559
+ // beside one of those is worse than `ok: false`: it tells an automated caller (or a human skimming --json) that
560
+ // everything was verified when it wasn't. A warn about project state (stale export, legacy layout, no map) is real
561
+ // advice and does not flip the roll-up.
562
+ function reportOk(checks: readonly Check[]): boolean {
563
+ return !checks.some((c) => c.status === "fail" || c.skipped === true);
564
+ }
565
+
566
+ const MARK: Record<Check["status"], string> = { ok: "✓", warn: "!", fail: "✗" };
567
+
568
+ const formatCheck = (c: Check) => `${MARK[c.status]} ${c.title}: ${c.detail}` + (c.next ? `\n → ${c.next}` : "");
569
+ function verdict(report: Report): string {
570
+ const n = (s: Check["status"]) => report.checks.filter((c) => c.status === s).length;
571
+ return n("fail") ? `${n("fail")} problem(s) to fix${n("warn") ? `, ${n("warn")} note(s)` : ""}.` : n("warn") ? `No blocking problems; ${n("warn")} note(s) above.` : "All good.";
572
+ }
573
+
574
+ async function main(argv: string[]): Promise<void> {
575
+ let waitSec = 10;
576
+ let json = false;
577
+ const usage = "Usage: dtwin doctor [--wait <seconds>] [--json]";
578
+ for (let i = 0; i < argv.length; i++) {
579
+ const a = argv[i];
580
+ if (a === undefined) continue; // cannot happen: i < argv.length
581
+ if (a === "--json") json = true;
582
+ else if (a === "--help" || a === "-h") {
583
+ console.log("dtwin doctor [--wait <seconds>] [--json]\n\n Checks the whole setup and says what to do next: Bun version, bridge token, daemon, port,\n whether the Figma plugin can connect (and whether it has the right token), and the project in\n the current directory. Changes nothing: no token is created, no file is written.\n --wait N seconds to wait for the plugin to connect (default 10; it retries every second); once it has,\n up to ~6.5 s more so every open file is seen and named\n --json machine output: { ok, checks: [{ id, title, status, detail, next, skipped }] }\n\n Exit code 1 only when a check is ✗ (a note, !, is not a failure).");
584
+ exitCli(0);
585
+ } else if (a === "--wait" || a.startsWith("--wait=")) {
586
+ const v = a === "--wait" ? argv[++i] : a.slice("--wait=".length);
587
+ waitSec = Number(v);
588
+ if (!v || !Number.isFinite(waitSec) || waitSec < 0) { console.error(`[dtwin doctor] error: --wait needs a number of seconds. ${usage}`); exitCli(1); }
589
+ } else { console.error(`[dtwin doctor] error: unknown argument: ${a}. ${usage}`); exitCli(1); }
590
+ }
591
+ // Human output streams; --json stays one document at the end (a consumer parses it whole).
592
+ const report = await run(json ? { waitSec } : {
593
+ waitSec,
594
+ onCheck: (c) => console.log(formatCheck(c)),
595
+ onWait: (sec) => console.log(`… waiting up to ${sec}s for the Figma plugin to connect, then a few seconds more for every open file to say its name (run it in Figma now; --wait 0 skips)`),
596
+ });
597
+ console.log(json ? JSON.stringify(report, null, 2) : "\n" + verdict(report));
598
+ // exitCode, not exit(): let stdout drain (a piped --json would otherwise be cut short).
599
+ process.exitCode = report.ok ? 0 : 1;
600
+ }
601
+
602
+ export { checkBun, checkToken, checkDaemon, sharedTokenNote, resolvePort, ephemeralPortWarning, checkPort, checkPlugin, checkProject, exportChecks, reportOk, probePort, probePlugin, run, main };
package/src/errmsg.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Thrown value -> message string. The ONE definition, shared across both consumers: the Bun side
2
+ // imports it directly, and the plugin bundle re-exports it from util.ts (esbuild inlines this
3
+ // dependency-free module, the same way it inlines pages-layout.ts). A bare `e.message` prints
4
+ // `undefined` for a thrown string, and the extractor's "never silent" discipline means every catch
5
+ // reports something — so this coercion has to hold everywhere, not per module system.
6
+ export const errMsg = (e: unknown): string =>
7
+ typeof e === "string" ? e : String((e && (e as { message?: unknown }).message) || e);
8
+
9
+ /** The first line of a thrown value's message — a Playwright or fs error carries its call log after it. */
10
+ export const firstLine = (e: unknown): string => errMsg(e).split("\n")[0] ?? "";
11
+
12
+ /** A thrown value's string `code` (an fs / Node error's ENOENT, ERR_PARSE_ARGS_…), else undefined. */
13
+ export const errCode = (e: unknown): string | undefined =>
14
+ e && typeof e === "object" && "code" in e && typeof e.code === "string" ? e.code : undefined;