@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/init.ts ADDED
@@ -0,0 +1,424 @@
1
+ // `dtwin init` — one command to set a PROJECT up for Design Twin, run from that project's root.
2
+ //
3
+ // Before this, onboarding was eleven manual steps across four docs: create design/, hand-write
4
+ // target.json, find the bridge token, hand-write a .mcp.json with an absolute path, remember the two
5
+ // `claude plugin` commands. Each is small; together they are where a new user gives up. init does
6
+ // every one that can be done from a terminal and PRINTS the two that cannot (importing the plugin
7
+ // into Figma and pasting the token are clicks in Figma's UI — nothing can do them for you).
8
+ //
9
+ // Rules it keeps:
10
+ // - Never overwrites. Every file it would write is skipped, and said so, if it already exists;
11
+ // .mcp.json is MERGED (an existing "figma" entry is left exactly as it is). The one exception is
12
+ // an entry of OURS that can no longer start (mcpEntryProblems: a bun or figma-mcp.ts that is gone,
13
+ // a Node-era `node …/dist/figma-mcp.js`, bun without its two flags, a `bunx @design-twin/dtwin mcp` that fetches the
14
+ // package at start-up): --mcp re-points that entry, under
15
+ // its own key and keeping its other fields, at this install — and says so (--dry-run only says so).
16
+ // - Needs no bridge, no plugin, no port: it only touches the project directory and the per-user
17
+ // token file, so it works while a daemon or the MCP server holds 8787.
18
+ // - The MCP registration is opt-in (--mcp): the MCP server binds the same port as the CLI, so a
19
+ // project that registers it cannot run one-shot `dtwin` pulls while Claude Code is open —
20
+ // a trade-off the user should choose, not inherit from a setup command.
21
+ //
22
+ // dtwin init # design/, design/target.json (detected stack), bridge token, next steps
23
+ // dtwin init --mcp # …and register the MCP server in ./.mcp.json
24
+ // dtwin init --dry-run # say what it would do, write nothing
25
+
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+ import { z } from "zod";
29
+ import * as tokenStore from "./token-store.ts";
30
+ import type { ResolvedToken } from "./token-store.ts";
31
+ import * as LAYOUT from "./project-layout.ts";
32
+ import { ifDefined, isRecord } from "./json-util.ts";
33
+ import { exitCli } from "./stdio.ts";
34
+
35
+ /** One .mcp.json server entry, as init writes it. */
36
+ export interface McpEntry {
37
+ command: string;
38
+ args: string[];
39
+ }
40
+
41
+ /** The part of .mcp.json init (and doctor) reads: `mcpServers`, when present, must be an object. A
42
+ * `null`, array or string there would crash init or be spread character-by-character into the
43
+ * rewritten file. Loose: every other key passes the check and is carried through untouched. */
44
+ // A JSON value's kind, for a message a person reads ("not a string", not zod's "received string").
45
+ const jsonKind = (x: unknown): string => (x === null ? "null" : Array.isArray(x) ? "an array" : typeof x === "object" ? "an object" : "a " + typeof x);
46
+ const McpJsonSchema = z.looseObject(
47
+ { mcpServers: z.record(z.string(), z.unknown(), { error: (iss) => `\`mcpServers\` must be an object of server entries, not ${jsonKind(iss.input)}` }).optional() },
48
+ { error: (iss) => `must be a JSON object, not ${jsonKind(iss.input)}` },
49
+ );
50
+
51
+ /** Check a parsed .mcp.json. On success, `doc` and `servers` are the user's OWN objects, not zod's
52
+ * copies: zod moves the keys it knows to the front and drops a key named "__proto__", and init
53
+ * rewrites this file — it must keep its key order and every entry. `error` is one line. */
54
+ function readMcpJson(raw: unknown): { doc: Record<string, unknown>; servers: Record<string, unknown> } | { error: string } {
55
+ const r = McpJsonSchema.safeParse(raw);
56
+ if (!r.success || !isRecord(raw)) return { error: r.success ? "must be a JSON object" : r.error.issues.map((i) => i.message).join("; ") };
57
+ return { doc: raw, servers: isRecord(raw.mcpServers) ? raw.mcpServers : {} };
58
+ }
59
+
60
+ /** One step of init's plan (see `plan`), discriminated on `kind`: the file-touching kinds carry their
61
+ * `path`, "write" its `content` (and `merge` for the one write allowed onto an existing file). */
62
+ export type InitAction =
63
+ | { kind: "skip"; path: string; note: string }
64
+ | { kind: "mkdir"; path: string; note: string }
65
+ | { kind: "write"; path: string; content: string; merge?: boolean; note: string }
66
+ | { kind: "note"; note: string }
67
+ | { kind: "token"; note: string };
68
+
69
+ export interface PlanOptions {
70
+ mcp?: boolean;
71
+ mcpEntry?: McpEntry;
72
+ /** Why no entry may be written from this install (bunxRefusal), or absent. */
73
+ refuseMcp?: string;
74
+ /** How entries already in .mcp.json are looked at (mcpEntryProblems). */
75
+ probe?: EntryProbe;
76
+ /** The resolved bridge token (token-store resolve()) — only its source/path/created are read. */
77
+ token: Pick<ResolvedToken, "source" | "path" | "created">;
78
+ }
79
+
80
+ // The profiles build-screen ships. Named in target.json when detection finds nothing, so the user can
81
+ // fill it in without going to look for the list.
82
+ export const PROFILES = ["web-tailwind", "web-css-modules", "react-native", "swiftui", "android-compose", "flutter"];
83
+
84
+ // The key the MCP server is registered under in .mcp.json — matches the server's own name
85
+ // (bridge/src/figma-mcp.ts), so its tools surface as mcp__designtwin__figma_status etc.
86
+ const MCP_KEY = "designtwin";
87
+
88
+ // Is this .mcp.json entry Design Twin's server, under ANY key? It points at figma-mcp.{ts,js} (what
89
+ // init writes: bridge/src/figma-mcp.ts — or the bridge/dist/figma-mcp.js / .mjs / .mts an older init
90
+ // wrote), or runs `@design-twin/dtwin mcp` (the npx/bunx form — ours, but one init repairs; `designtwin` is the package's
91
+ // name before it moved under the npm org). One rule for init (don't double-register) and doctor (report it).
92
+ const PACKAGE_NAMES: readonly string[] = ["@design-twin/dtwin", "designtwin"];
93
+ const isOurMcpEntry = (e: unknown): boolean => {
94
+ // Untyped JSON from the user's .mcp.json: only an object can carry `args` (JSON has no other shape that does).
95
+ const args: unknown = e && typeof e === "object" ? (e as { args?: unknown }).args : undefined;
96
+ return !!e && Array.isArray(args) && (args.some((a) => FIGMA_MCP_SCRIPT.test(String(a))) || (args.some((a) => PACKAGE_NAMES.includes(String(a))) && args.includes("mcp")));
97
+ };
98
+ const FIGMA_MCP_SCRIPT = /(^|[\\/])figma-mcp\.(mjs|mts|ts|js)$/;
99
+
100
+ // The flags every user-facing bun invocation carries (the bin shebang's): no auto-install from the registry, and the
101
+ // project's .env stays out of the server's environment.
102
+ const BUN_FLAGS = ["--no-install", "--no-env-file"] as const;
103
+
104
+ /** The bun to name in .mcp.json: the one on PATH (Bun.which) when it IS the bun running now — a stable spelling such as
105
+ * ~/.bun/bin/bun or Homebrew's /opt/homebrew/bin/bun, which survives an upgrade — else this process's own executable.
106
+ * process.execPath alone is the resolved file, and for a Homebrew (or any versioned) install that is a versioned path
107
+ * (…/Cellar/bun/1.4.2/bin/bun) the next `brew upgrade` deletes, leaving Claude Code a server it cannot start. */
108
+ function stableBunPath(execPath: string = process.execPath, onPath: string | null = Bun.which("bun")): string {
109
+ if (!onPath) return execPath;
110
+ try { return fs.realpathSync(onPath) === fs.realpathSync(execPath) ? onPath : execPath; } catch { return execPath; }
111
+ }
112
+
113
+ /** The bunx cache directory `dir` sits in, or null. `bunx @design-twin/dtwin …` installs the package into a cache tree
114
+ * (`<tmp or install cache>/.bunx-<uid>/<pkg>`, `<tmp>/bunx-<uid>-<pkg>` on Windows and older Bun) that Bun refreshes
115
+ * and deletes on its own schedule, so a .mcp.json path into it is a server that one day does not start. */
116
+ function bunxCacheOf(dir: string): string | null {
117
+ const m = /^(.*?[\\/]\.?bunx-[^\\/]+)(?:[\\/]|$)/.exec(dir);
118
+ return m?.[1] ?? null;
119
+ }
120
+ const bunxRefusal = (cache: string): string =>
121
+ `this dtwin runs from a bunx cache (${cache}) that Bun replaces or deletes on its own — a .mcp.json entry pointing into it would stop starting one day. ` +
122
+ "Install it for good (`bun add -g @design-twin/dtwin`, or in a clone: `bun install`, then `cd bridge && bun link`), and run `dtwin init --mcp` from that install";
123
+
124
+ /** How to look at the machine (seams for the suites): does a path exist, and what does a bare command resolve to. */
125
+ export interface EntryProbe {
126
+ exists?: (p: string) => boolean;
127
+ which?: (cmd: string) => string | null;
128
+ /** Where a relative path in the entry resolves from (Claude Code starts the server in the project root). */
129
+ cwd?: string;
130
+ }
131
+
132
+ /** What is wrong with an .mcp.json entry of OURS (isOurMcpEntry), one phrase each — empty when it will start: its command
133
+ * exists, it runs on Bun (not Node, not the old bridge/dist build), it names a server script (not a package bunx would
134
+ * fetch), that script exists outside a bunx cache, and bun gets the two flags before that script. The ONE rule for init (repair it) and doctor (report it). */
135
+ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => Bun.which(c), cwd = process.cwd() }: EntryProbe = {}): string[] {
136
+ if (!isRecord(e) || typeof e.command !== "string" || e.command === "") return ["it has no `command`"];
137
+ const command = e.command;
138
+ const args = Array.isArray(e.args) ? e.args.map(String) : [];
139
+ const out: string[] = [];
140
+ const base = path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat)$/, "");
141
+ const nodeShaped = ["node", "nodejs", "npx", "npm"].includes(base);
142
+ if (nodeShaped) out.push(`it runs on Node (\`${command}\`) — Design Twin runs on Bun only`);
143
+ const found = /[\\/]/.test(command) ? exists(path.resolve(cwd, command)) : which(command) !== null;
144
+ if (!found) out.push(`its command \`${command}\` ${/[\\/]/.test(command) ? "does not exist" : "is not on PATH"}`);
145
+ const i = args.findIndex((a) => FIGMA_MCP_SCRIPT.test(a));
146
+ const script = args[i];
147
+ // No server script: the package-runner form (`bunx @design-twin/dtwin mcp`, `bun x @design-twin/dtwin mcp`, an older `npx designtwin
148
+ // mcp`). It names a package, not this install: bunx fetches it from the registry at start-up (network, unpinned, outside
149
+ // any lockfile) and, with or without --no-install, may run whatever copy sits in its own cache — the one Bun replaces or
150
+ // deletes on its own. That is the ephemeral registration init refuses to write, so it is one to repair, not to keep.
151
+ if (script === undefined) {
152
+ out.push(`it runs the server by package name (\`${[command, ...args].join(" ")}\`) — bunx fetches that from the registry, or a bunx cache Bun replaces or deletes on its own, at every start`);
153
+ return out;
154
+ }
155
+ const abs = path.resolve(cwd, script);
156
+ // figma-mcp.js / .mjs (bridge/dist, or the older bridge/figma-mcp.mjs) is the Node-era build: the package ships .ts now
157
+ if (/\.m?js$/.test(script)) out.push(`it points at the old Node build (${script})`);
158
+ else if (!exists(abs)) out.push(`its server script ${script} does not exist`);
159
+ const cache = bunxCacheOf(abs);
160
+ if (cache) out.push(`its server script is in a bunx cache (${cache}) that Bun replaces or deletes on its own`);
161
+ if (!nodeShaped) {
162
+ if (base !== "bun") out.push(`it does not run the server with bun (\`${command}\`)`);
163
+ else {
164
+ const missing = BUN_FLAGS.filter((f) => { const at = args.indexOf(f); return at < 0 || at > i; });
165
+ if (missing.length) out.push(`bun runs it without ${missing.join(" and ")} (a project's .env would reach the server, and a missing import would be fetched from the registry)`);
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Does package.json list vite in dependencies or devDependencies?
172
+ function usesVite(cwd: string): boolean {
173
+ let raw: unknown;
174
+ try { raw = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as unknown; } catch { return false; }
175
+ return isRecord(raw) && [raw.dependencies, raw.devDependencies].filter(isRecord).some((d) => "vite" in d);
176
+ }
177
+
178
+ // Does package.json list Tailwind v4 (tailwindcss not pinned to v3, or its v4-only Vite/PostCSS plugin) in dependencies or devDependencies?
179
+ function usesTailwind(cwd: string): boolean {
180
+ let raw: unknown;
181
+ try { raw = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as unknown; } catch { return false; }
182
+ if (!isRecord(raw)) return false;
183
+ const deps = [raw.dependencies, raw.devDependencies].filter(isRecord);
184
+ // @tailwindcss/vite and @tailwindcss/postcss exist only for v4. `tailwindcss` itself counts unless its
185
+ // range is clearly v3 (^3 / ~3 / 3.x / >=3 <4): v3 scans only its `content` globs, so the note would be wrong.
186
+ if (deps.some((d) => "@tailwindcss/vite" in d || "@tailwindcss/postcss" in d)) return true;
187
+ const range = deps.map((d) => d.tailwindcss).find((v) => v !== undefined);
188
+ if (range === undefined) return false;
189
+ // Each `||` alternative on its own: "^3.0.0 || ^4.0.0" allows v4, so it still gets the note.
190
+ const isV3 = (r: string): boolean => /^\s*(?:[\^~]|>=?\s*)?v?3(?:$|[.\s<x*])/.test(r) && (!/^\s*>/.test(r) || /<\s*v?4/.test(r)); // ">=3" alone may be v4
191
+ return !(typeof range === "string" && range.split("||").every(isV3));
192
+ }
193
+
194
+ // Same detection order build-screen's step 0 documents — first match wins, most specific first.
195
+ function detectProfile(cwd: string): { profile: string; because: string } | null {
196
+ const has = (f: string) => fs.existsSync(path.join(cwd, f));
197
+ const read = (f: string): string => { try { return fs.readFileSync(path.join(cwd, f), "utf8"); } catch { return ""; } };
198
+ const ls: string[] = (() => { try { return fs.readdirSync(cwd); } catch { return []; } })();
199
+ type PackageJson = { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
200
+ let pkg: PackageJson | null = null;
201
+ try { pkg = JSON.parse(read("package.json")) as PackageJson; } catch { /* no/invalid package.json */ }
202
+ if (pkg) {
203
+ const deps: Record<string, string | undefined> = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
204
+ if (deps["react-native"] || deps.expo) return { profile: "react-native", because: "react-native/expo in package.json" };
205
+ if (deps.tailwindcss || deps["@tailwindcss/vite"] || deps["@tailwindcss/postcss"]) return { profile: "web-tailwind", because: "tailwindcss in package.json" };
206
+ }
207
+ if (has("pubspec.yaml") && /^\s*flutter\s*:/m.test(read("pubspec.yaml"))) return { profile: "flutter", because: "flutter in pubspec.yaml" };
208
+ if (has("Package.swift") || ls.some((f) => f.endsWith(".xcodeproj") || f.endsWith(".xcworkspace"))) return { profile: "swiftui", because: "an Xcode project / Package.swift" };
209
+ for (const g of ["build.gradle.kts", "build.gradle", "app/build.gradle.kts", "app/build.gradle"]) {
210
+ if (/compose/i.test(read(g))) return { profile: "android-compose", because: "Compose in " + g };
211
+ }
212
+ if (pkg) return { profile: "web-css-modules", because: "a package.json with no Tailwind/React Native (the generic web profile)" };
213
+ return null;
214
+ }
215
+
216
+ // Pure planner: what init WOULD do in `cwd`. Returns { actions:[{kind, path?, content?, note}], steps:[…] }.
217
+ // Split from the writer so the test suite can assert the plan without touching a real HOME.
218
+ function plan(cwd: string, { mcp = false, mcpEntry, refuseMcp, probe, token }: PlanOptions): InitAction[] {
219
+ const actions: InitAction[] = [];
220
+ const rel = (p: string) => path.relative(cwd, p) || ".";
221
+
222
+ const designDir = path.join(cwd, LAYOUT.DESIGN_DIR);
223
+ actions.push(fs.existsSync(designDir) ? { kind: "skip", path: rel(designDir), note: "exists" } : { kind: "mkdir", path: rel(designDir), note: "your decisions live here" });
224
+
225
+ // design/export/ is created up front, empty, precisely so the split is visible BEFORE the first
226
+ // pull rather than discovered after one. dtwin writes only here.
227
+ const exportDir = path.join(cwd, LAYOUT.EXPORT_DIR);
228
+ actions.push(fs.existsSync(exportDir) ? { kind: "skip", path: rel(exportDir), note: "exists" } : { kind: "mkdir", path: rel(exportDir), note: "where every pull lands — safe to delete and re-pull" });
229
+
230
+ // The README is the only thing that can tell a future reader which half of design/ a re-pull is
231
+ // allowed to destroy. A comment in our source cannot; a directory name alone only hints.
232
+ const readme = path.join(designDir, "README.md");
233
+ if (fs.existsSync(readme)) actions.push({ kind: "skip", path: rel(readme), note: "exists — left as is" });
234
+ else actions.push({ kind: "write", path: rel(readme), content: LAYOUT.README, note: "what dtwin owns vs what you own" });
235
+
236
+ // target.json is written ALWAYS, even when no stack could be detected.
237
+ //
238
+ // Skipping it in that case would make `dtwin init --help`'s own promise ("Creates design/
239
+ // and design/target.json") false, and leave every later step reading a file that is simply not there
240
+ // — the verify skill's step 1 `cat`s it and would move on in silence. A file that
241
+ // says "nobody has decided yet" is a far better artifact than an absent one: build-screen can fill
242
+ // it in, and everything downstream has one place to look.
243
+ const targetFile = path.join(cwd, LAYOUT.TARGET_FILE);
244
+ if (fs.existsSync(targetFile)) actions.push({ kind: "skip", path: rel(targetFile), note: "exists — left as is" });
245
+ else {
246
+ const d = detectProfile(cwd);
247
+ const doc = d
248
+ ? { profile: d.profile, detectedFrom: d.because }
249
+ : { profile: null, note: "No stack was detected in this directory. build-screen asks on its first run and writes the answer here; audit-design reads it to pick touch-target minimums. Set it by hand if you already know: one of " + PROFILES.join(", ") + "." };
250
+ actions.push({
251
+ kind: "write",
252
+ path: rel(targetFile),
253
+ content: JSON.stringify(doc, null, 2) + "\n",
254
+ note: d ? `stack detected: ${d.profile} (${d.because})` : "no stack detected here — written with profile:null so build-screen has somewhere to record the answer",
255
+ });
256
+ }
257
+
258
+ // design/ holds regenerable exports AND hand-owned decisions; only the second kind is lost forever.
259
+ // Say so where the user will see it, rather than editing their .gitignore for them.
260
+ const gi = path.join(cwd, ".gitignore");
261
+ const ignored = fs.existsSync(gi) && /^\/?design\/?\s*$/m.test(fs.readFileSync(gi, "utf8"));
262
+ if (ignored) actions.push({ kind: "note", note: ".gitignore ignores design/ — target.json, codeconnect.local.json, plan/ and audit/ under it are hand-authored and are NOT regenerable. Ignore only the export: replace `design/` with `design/export/`" });
263
+
264
+ // Suggest only: a Tailwind project would otherwise compile class names quoted in design/.
265
+ const tailwind = usesTailwind(cwd);
266
+ if (tailwind) actions.push({ kind: "note", note: LAYOUT.TAILWIND_SOURCE_NOT_NOTE });
267
+
268
+ // Suggest only: with Tailwind v4 scanning design/, a rewrite there makes Vite reload the open page
269
+ // (proven only for vite + Tailwind v4 — see VITE_WATCH_IGNORED_NOTE for why vite alone gets no note).
270
+ if (tailwind && usesVite(cwd)) actions.push({ kind: "note", note: LAYOUT.VITE_WATCH_IGNORED_NOTE });
271
+ // design/verify/ already ignored — any spelling: design/verify, /design/verify/, design/verify/**, design/verify/*
272
+ // (or the whole design/ tree, which the note above already warns about)
273
+ const verifyIgnored = /^\/?design(?:\/verify)?(?:\/(?:\*\*?)?)?\s*$/m.test(fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "");
274
+ if (!verifyIgnored) actions.push({ kind: "note", note: LAYOUT.VERIFY_GITIGNORE_NOTE });
275
+
276
+ if (mcp) {
277
+ const file = path.join(cwd, ".mcp.json");
278
+ let doc: object = {};
279
+ let servers: Record<string, unknown> = {};
280
+ let bad: string | null = null;
281
+ if (fs.existsSync(file)) {
282
+ let raw: unknown;
283
+ try { raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown; } catch { bad = "exists but is not valid JSON"; }
284
+ if (bad === null) {
285
+ const r = readMcpJson(raw);
286
+ if ("error" in r) bad = r.error;
287
+ else { doc = r.doc; servers = r.servers; }
288
+ }
289
+ }
290
+ if (bad !== null) actions.push({ kind: "skip", path: rel(file), note: bad + " — fix it, then re-run with --mcp" });
291
+ else {
292
+ // Registered as "designtwin", never "figma": that is the name Figma's own MCP server is usually
293
+ // given, and Claude Code loads only one server per name — sharing it would silently drop one.
294
+ // Ours under ANY key (older inits wrote "figma"): a second entry would start a second server
295
+ // and the two would fight over port 8787.
296
+ const mine = Object.keys(servers).find((k) => isOurMcpEntry(servers[k]));
297
+ // Ours, but it cannot start: a bun or a figma-mcp.ts that has since moved (a deleted clone, a Homebrew upgrade, a
298
+ // bunx cache Bun cleaned), a Node-era entry (`node …/bridge/dist/figma-mcp.js`), or one that starts only as long
299
+ // as bunx can fetch the package (`bunx @design-twin/dtwin mcp`). Left as is, Claude Code would
300
+ // show a failed server and no tools; the one write that fixes it is re-pointing THAT entry, under its own key, at
301
+ // this install. Every other field the user put on it (env, …) is kept.
302
+ const broken = mine === undefined ? [] : mcpEntryProblems(servers[mine], { cwd, ...probe });
303
+ const old = mine === undefined ? undefined : servers[mine];
304
+ if (mine !== undefined && broken.length && refuseMcp) actions.push({ kind: "skip", path: rel(file), note: `the "${mine}" entry needs repair (${broken.join("; ")}), but ${refuseMcp}` });
305
+ else if (mine !== undefined && broken.length && mcpEntry && isRecord(old)) {
306
+ const next = { ...doc, mcpServers: { ...servers, [mine]: { ...old, command: mcpEntry.command, args: mcpEntry.args } } };
307
+ actions.push({ kind: "write", merge: true, path: rel(file), content: JSON.stringify(next, null, 2) + "\n", note: `REPAIRED the "${mine}" MCP server entry — ${broken.join("; ")}. It now runs ${[mcpEntry.command, ...mcpEntry.args].join(" ")} (restart Claude Code to pick it up)` });
308
+ }
309
+ else if (mine) actions.push({ kind: "skip", path: rel(file), note: `Design Twin's MCP server is already registered as "${mine}" — left as is${broken.length ? ` (it needs repair: ${broken.join("; ")})` : ""}` });
310
+ else if (servers[MCP_KEY]) actions.push({ kind: "skip", path: rel(file), note: `already has a "${MCP_KEY}" server that is not Design Twin's — left as is` });
311
+ else if (refuseMcp) actions.push({ kind: "skip", path: rel(file), note: `not registered: ${refuseMcp}` });
312
+ else {
313
+ const next = { ...doc, mcpServers: { ...servers, [MCP_KEY]: mcpEntry } };
314
+ // merge:true — the ONE write allowed onto an existing file: it re-serialises the user's own
315
+ // document with one key added. Everything else is written with "wx" and cannot clobber.
316
+ actions.push({ kind: "write", merge: fs.existsSync(file), path: rel(file), content: JSON.stringify(next, null, 2) + "\n", note: `registered the "${MCP_KEY}" MCP server (enable it with /mcp, then restart Claude Code). While it runs it holds port 8787 — use its writeToDisk:true instead of one-shot dtwin pulls` });
317
+ }
318
+ }
319
+ }
320
+
321
+ actions.push({ kind: "token", note: token.created ? `bridge token created (${token.path})` : token.source === "env" ? "bridge token: using FIGMA_BRIDGE_TOKEN from the environment" : token.source === "ephemeral" ? "bridge token could NOT be saved (read-only config dir?) — it will change every run" : `bridge token already saved (${token.path})` });
322
+ return actions;
323
+ }
324
+
325
+ function apply(cwd: string, actions: InitAction[], log: (m: string) => void): void {
326
+ for (const a of actions) {
327
+ if (a.kind === "mkdir") fs.mkdirSync(path.join(cwd, a.path), { recursive: true });
328
+ if (a.kind === "write") { fs.mkdirSync(path.dirname(path.join(cwd, a.path)), { recursive: true }); fs.writeFileSync(path.join(cwd, a.path), a.content, { flag: a.merge ? "w" : "wx" }); }
329
+ const tag = a.kind === "write" ? "wrote " : a.kind === "mkdir" ? "created" : a.kind === "skip" ? "skipped" : a.kind === "note" ? "note " : "token ";
330
+ log(`${tag} ${"path" in a ? a.path + " — " : ""}${a.note}`);
331
+ }
332
+ }
333
+
334
+ function main(argv: string[]): void {
335
+ const known = ["--mcp", "--dry-run", "--help", "-h"];
336
+ const bad = argv.filter((a) => !known.includes(a));
337
+ if (bad.length) { console.error(`[dtwin init] error: unknown argument${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}. Usage: dtwin init [--mcp] [--dry-run]`); exitCli(1); }
338
+ if (argv.includes("--help") || argv.includes("-h")) {
339
+ console.log(
340
+ "dtwin init [--mcp] [--dry-run]\n\n" +
341
+ " Run in the root of the project you are BUILDING. Creates exactly these four things:\n" +
342
+ " design/ the directory your decisions live under\n" +
343
+ " design/export/ where every dtwin pull lands, empty at first — safe to delete and re-pull\n" +
344
+ " design/README.md which half of design/ a re-pull is allowed to destroy\n" +
345
+ " design/target.json the detected stack, or profile:null for build-screen to fill in\n" +
346
+ " It does NOT create codeconnect.local.json, plan/, audit/ or verify/ — those are written later,\n" +
347
+ " on demand, by map-bootstrap.ts, build-screen, audit-design and verify respectively.\n" +
348
+ " …makes sure a bridge token exists, and prints the remaining steps.\n\n" +
349
+ " --mcp also register the Design Twin MCP server in ./.mcp.json (merged, never overwritten;\n" +
350
+ " an entry of ours that can no longer start — a moved bun or figma-mcp.ts, a Node-era\n" +
351
+ " entry — is repaired in place, and the output says so)\n" +
352
+ " --dry-run print what would happen; write nothing (no token is created either)\n\n" +
353
+ " Never overwrites an existing file."
354
+ );
355
+ exitCli(0);
356
+ }
357
+ const dry = argv.includes("--dry-run");
358
+ const cwd = process.cwd();
359
+ const log = (m: string) => console.error("[dtwin init] " + (dry ? "(dry run) " : "") + m);
360
+
361
+ const token = tokenStore.resolve({ persist: !dry });
362
+ // Absolute paths to THIS install's server and to the bun running it now: works for a repo clone and a
363
+ // global install alike, and needs no network at Claude Code start-up (unlike `bunx @design-twin/dtwin mcp`).
364
+ // The server is the figma-mcp.ts that sits beside THIS file (a checkout and an install both carry the
365
+ // source; there is no build). The command is an absolute bun, not "bun": Claude Code starts the server
366
+ // with ITS environment, whose PATH may not have the ~/.bun/bin a shell profile adds — and the bun that
367
+ // ran init is one this package is known to work with, spelt the way that survives its upgrades
368
+ // (stableBunPath). The flags are the shebang's (figma-pull.ts): no auto-install from the network, and
369
+ // the project's .env stays out of the server's environment.
370
+ const mcpEntry: McpEntry = { command: stableBunPath(), args: [...BUN_FLAGS, path.join(import.meta.dirname, "figma-mcp.ts")] };
371
+ // Run through `bunx @design-twin/dtwin init`: this very file is in bunx's cache, so no entry may point at it.
372
+ const cache = bunxCacheOf(import.meta.dirname);
373
+ const refuseMcp = cache ? bunxRefusal(cache) : undefined;
374
+ if (cache && !argv.includes("--mcp")) log(`note this dtwin runs from a bunx cache (${cache}) — install it for good (\`bun add -g @design-twin/dtwin\`, or in a clone: \`bun install\`, then \`cd bridge && bun link\`) to have \`dtwin\` on PATH and to register the MCP server`);
375
+ const actions = plan(cwd, { mcp: argv.includes("--mcp"), mcpEntry, ...ifDefined("refuseMcp", refuseMcp), token });
376
+ if (dry) for (const a of actions) log(`${a.kind === "write" ? "would write" : a.kind === "mkdir" ? "would create" : a.kind} ${"path" in a ? a.path + " — " : ""}${a.note}`);
377
+ else apply(cwd, actions, log);
378
+
379
+ // The Figma plugin's manifest: a repo checkout's (bridge/src/ -> ../../figma-plugin/), else the copy the npm
380
+ // package carries (../figma-plugin/, put there by bridge/pack.ts). Neither: the step names the repo instead.
381
+ const manifest = [path.join(import.meta.dirname, "..", "..", "figma-plugin", "manifest.json"), path.join(import.meta.dirname, "..", "figma-plugin", "manifest.json")].find((m) => fs.existsSync(m));
382
+ // Step 3 is already done for anyone who reached init THROUGH the installed plugin's help skill —
383
+ // which is the documented path. Claude Code records installed plugins under ~/.claude; when the
384
+ // marker is there, saying "install the plugin" is noise at best and confusing at worst.
385
+ const pluginInstalled = (() => {
386
+ const home = process.env.HOME || process.env.USERPROFILE;
387
+ if (!home) return false;
388
+ const root = path.join(home, ".claude", "plugins");
389
+ // installed_plugins.json is the authoritative record; the per-plugin directories under data/ and
390
+ // repos/ are the fallback for older layouts. Any of them naming designtwin means step 3 is done.
391
+ try {
392
+ if (/designtwin/i.test(fs.readFileSync(path.join(root, "installed_plugins.json"), "utf8"))) return true;
393
+ } catch { /* absent — try the directories */ }
394
+ for (const p of ["data", "repos", "marketplaces", "."]) {
395
+ try {
396
+ if (fs.readdirSync(path.join(root, p)).some((d) => /designtwin/i.test(d))) return true;
397
+ } catch { /* not there — fall through */ }
398
+ }
399
+ return false;
400
+ })();
401
+
402
+ const steps = [
403
+ "In Figma DESKTOP: Plugins → Development → Import plugin from manifest… → " + (manifest ?? "figma-plugin/manifest.json from a clone of the Design Twin repo (this install does not include it)"),
404
+ "Run the plugin (Plugins → Development → Design Twin), paste the bridge token into its \"Bridge token\" field, Save: dtwin --show-token | pbcopy",
405
+ ];
406
+ if (!pluginInstalled) {
407
+ steps.push("In Claude Code, once: claude plugin marketplace add <path-or-repo of Design Twin> then claude plugin install designtwin@designtwin-marketplace");
408
+ }
409
+ steps.push(
410
+ "Then, with the Figma file open and the plugin running: dtwin list → /designtwin:extract → /designtwin:audit-design <screen> → /designtwin:build-screen <screen>"
411
+ );
412
+ console.log("\nNext — the steps only you can do:\n" + steps.map((s, i) => ` ${i + 1}. ${s}`).join("\n"));
413
+ // The one refusal a first command reliably hits. doctor names it after the fact; saying it here, in
414
+ // the output whose whole job is "the steps left", is what stops `dtwin list` failing on step 4
415
+ // for anyone with two Figma files open.
416
+ console.log(
417
+ "\n If more than one Figma file is connected, every command needs to say which:\n" +
418
+ " dtwin list clients # the address book\n" +
419
+ " dtwin list --client <part of the file name>\n" +
420
+ " `dtwin doctor` tells you how many are connected and lists them.\n"
421
+ );
422
+ }
423
+
424
+ export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };
@@ -0,0 +1,7 @@
1
+ // The length an INSTANCE's `overrides` list is cut to. The plugin (components.ts instanceOverrides)
2
+ // keeps the first OVERRIDE_CAP entries and warns; a consumer that sees a list this long cannot know
3
+ // what was cut, so it treats the list as possibly incomplete (asset-owners.ts, design-to-code's
4
+ // audit). Defined once because the consumers infer truncation from the producer's cap: two copies
5
+ // that drift apart would read a cut list as complete. ES2019-safe and import-free: the plugin
6
+ // bundles it.
7
+ export const OVERRIDE_CAP = 100;
@@ -0,0 +1,79 @@
1
+ // The writer for JSON files a person also edits (plans, the component map): atomic, and in the file's own format.
2
+ // Shared by bridge/ and design-to-code/ (the layer may import bridge/src; bridge cannot import the layer).
3
+ import fs from "node:fs";
4
+ import { writeFileAtomic } from "./atomic-write.ts";
5
+ import { errCode } from "./errmsg.ts";
6
+ import { acquireFileLock, lockFileOf } from "./file-lock.ts";
7
+ import type { FileLockOpts, HeldLock } from "./file-lock.ts";
8
+
9
+ // The file's own indentation: the first indented line's leading whitespace (2 spaces when the file has none).
10
+ const indentOf = (text: string): string => /\n([ \t]+)\S/.exec(text)?.[1] ?? " ";
11
+
12
+ /** `value` as the text of the file it replaces — that file's BOM, indentation, line endings and final newline.
13
+ * `raw` is the file as read; null (no file yet) → 2 spaces, LF, a final newline. */
14
+ export function formatJsonLike(value: unknown, raw: string | null): string {
15
+ if (raw === null) return JSON.stringify(value, null, 2) + "\n";
16
+ const bom = raw.charCodeAt(0) === 0xfeff ? "\ufeff" : "";
17
+ const text = raw.slice(bom.length);
18
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
19
+ return bom + JSON.stringify(value, null, indentOf(text)).split("\n").join(eol) + (/\n$/.test(text) ? eol : "");
20
+ }
21
+
22
+ /** Formats `value` like the file (formatJsonLike over `raw`, else the file as it is on disk now) and writes it
23
+ * atomically — a tmp file beside it, then a rename, so a kill mid-write leaves the old file whole — only when the
24
+ * bytes change. True when written. */
25
+ export function writeJsonLike(file: string, value: unknown, raw?: string | null): boolean {
26
+ let was: string | null = raw ?? null;
27
+ if (raw === undefined) try { was = fs.readFileSync(file, "utf8"); } catch { was = null; }
28
+ const out = formatJsonLike(value, was);
29
+ if (out === was) return false;
30
+ writeFileAtomic(file, out);
31
+ return true;
32
+ }
33
+
34
+ /** How often updateJsonLike starts over when the file changed under it. */
35
+ const UPDATE_TRIES = 3;
36
+ const readIfThere = (file: string): string | null => {
37
+ try { return fs.readFileSync(file, "utf8"); } catch (e) { if (errCode(e) === "ENOENT") return null; throw e; }
38
+ };
39
+ export interface UpdateJsonOpts extends FileLockOpts {
40
+ /** the lock file to hold (default `<file>.lock` beside it) */
41
+ lockFile?: string;
42
+ /** runs right after the write, still under the lock — only when the file was written */
43
+ afterWrite?: () => void;
44
+ /** maps what taking the lock throws to what is thrown instead (returned unchanged: kept) — the lock may live somewhere
45
+ * other than the file (a plan's, in the run cache), so its caller words a refusal there */
46
+ lockRefused?: (e: unknown) => unknown;
47
+ }
48
+ /**
49
+ * Read-modify-write of a JSON file several writers share (a plan: --record-plan, --accept, plan-skeleton --out, the
50
+ * verify-build hook). Under the lock `opts.lockFile` (default `<file>.lock`; file-lock.ts) it reads the file (null
51
+ * when there is none), gives the text to `fn`, which returns the new value, and writes it like writeJsonLike —
52
+ * formatted like the file, atomic, only when the bytes change; true when written. Two tool writers never interleave:
53
+ * the second reads what the first wrote. A writer that takes no lock (an agent's Edit/Write) is caught by reading the
54
+ * file again before the rename — changed bytes start over from them (up to UPDATE_TRIES times, then it throws); its
55
+ * write landing between that re-read and the rename is still lost. `fn` may run more than once, so it computes from
56
+ * its argument only. Throws LockBusy (file-lock.ts) when another writer holds the lock past `opts.waitMs`, LockLost
57
+ * when the lock was taken from this writer before its write (nothing written), and whatever reading the file throws
58
+ * other than ENOENT (an unreadable plan is never treated as absent and overwritten); an error taking the lock as
59
+ * `opts.lockRefused` maps it.
60
+ */
61
+ export function updateJsonLike(file: string, fn: (raw: string | null) => unknown, opts: UpdateJsonOpts = {}): boolean {
62
+ const { lockFile = lockFileOf(file), afterWrite, lockRefused, ...lockOpts } = opts;
63
+ let lock: HeldLock;
64
+ try { lock = acquireFileLock(lockFile, lockOpts); } catch (e) { throw lockRefused ? lockRefused(e) : e; }
65
+ try {
66
+ for (let tries = 1; ; tries++) {
67
+ const raw = readIfThere(file);
68
+ const out = formatJsonLike(fn(raw), raw);
69
+ if (out === raw) return false;
70
+ if (readIfThere(file) === raw) {
71
+ lock.assertMine();
72
+ writeFileAtomic(file, out);
73
+ afterWrite?.();
74
+ return true;
75
+ }
76
+ if (tries >= UPDATE_TRIES) throw new Error(`${file} kept changing while it was being updated (${UPDATE_TRIES} tries) — retry once its other writer is done`);
77
+ }
78
+ } finally { lock.release(); }
79
+ }
@@ -0,0 +1,108 @@
1
+ // A bounded measure of `JSON.stringify(value, null, 2).length`: answers "does the pretty-printed text fit in
2
+ // `limit` UTF-16 code units?" without building the text, and stops walking the moment the running total passes
3
+ // the limit. The inline-vs-spill decision for an MCP export reply needs only that answer, and a real export is
4
+ // tens of megabytes — serialising it just to learn it is over a ~48 KB cap costs seconds of CPU and a
5
+ // second copy of the export in memory, for a string that is then thrown away.
6
+ //
7
+ // The length is the one JSON.stringify produces (ECMA-262 SerializeJSONProperty / QuoteJSONString;
8
+ // https://tc39.es/ecma262/#sec-serializejsonproperty): numbers print as Number::toString (non-finite → null),
9
+ // strings escape `"`, `\`, control characters (< 0x20) and lone surrogates (a well-formed surrogate pair is
10
+ // left as is), a property whose value is undefined / a function / a symbol is skipped (null inside an array),
11
+ // an empty object or array prints as `{}` / `[]`, and with a 2-space indent each member sits on its own line
12
+ // with `": "` after a key.
13
+ //
14
+ // Anything whose size JSON.stringify decides through code this walker does not mirror (a toJSON method, a
15
+ // bigint, a boxed primitive, a class instance, a very deep or cyclic value) is reported as "unsupported" so the
16
+ // caller measures it the old way; the answer is never a guess.
17
+
18
+ export type JsonSize =
19
+ | { kind: "fits"; length: number }
20
+ | { kind: "over" }
21
+ | { kind: "unsupported" };
22
+
23
+ // Deeper than any real export (an IR tree is a few dozen levels); past it the recursion could overflow the
24
+ // stack, so the value is measured by JSON.stringify itself, which reports a cycle or a too-deep value its own way.
25
+ const MAX_DEPTH = 200;
26
+
27
+ // Characters that make a string's quoted form longer than its length + 2.
28
+ const NEEDS_ESCAPE = /["\\\u0000-\u001f\ud800-\udfff]/;
29
+
30
+ /** Length of `JSON.stringify(s)`. */
31
+ function quotedLength(s: string): number {
32
+ if (!NEEDS_ESCAPE.test(s)) return s.length + 2;
33
+ let n = s.length + 2;
34
+ for (let i = 0; i < s.length; i++) {
35
+ const c = s.charCodeAt(i);
36
+ if (c === 0x22 || c === 0x5c) n += 1; // \" \\
37
+ else if (c < 0x20) n += c === 8 || c === 9 || c === 10 || c === 12 || c === 13 ? 1 : 5; // \b \t \n \f \r, else \u00XX
38
+ else if (c >= 0xd800 && c <= 0xdbff) {
39
+ const d = s.charCodeAt(i + 1);
40
+ if (d >= 0xdc00 && d <= 0xdfff) i++; // a pair is written raw
41
+ else n += 5; // a lone high surrogate becomes \udXXX
42
+ } else if (c >= 0xdc00 && c <= 0xdfff) n += 5; // a lone low surrogate
43
+ }
44
+ return n;
45
+ }
46
+
47
+ // The three kinds JSON.stringify drops from an object and turns into null inside an array. (At the top level
48
+ // they have no JSON text at all; walk() reports them as unsupported there.)
49
+ const isOmitted = (v: unknown): boolean => v === undefined || typeof v === "function" || typeof v === "symbol";
50
+
51
+ /** Whether `JSON.stringify(value, null, 2)` is at most `limit` characters long — and its exact length when it is. */
52
+ export function prettyJsonSize(value: unknown, limit: number): JsonSize {
53
+ let total = 0;
54
+ let unsupported = false;
55
+ // Adds to the running total; false once the limit is passed or the value is unsupported, which unwinds the walk.
56
+ const add = (n: number): boolean => { total += n; return total <= limit; };
57
+
58
+ const walk = (v: unknown, depth: number): boolean => {
59
+ if (v === null) return add(4);
60
+ if (typeof v === "string") return add(quotedLength(v));
61
+ if (typeof v === "number") return add(Number.isFinite(v) ? String(v).length : 4);
62
+ if (typeof v === "boolean") return add(v ? 4 : 5);
63
+ if (typeof v !== "object") { unsupported = true; return false; } // a bigint (stringify throws), or a top-level undefined / function / symbol
64
+ if (depth > MAX_DEPTH) { unsupported = true; return false; }
65
+ if (Array.isArray(v)) {
66
+ if (Object.getPrototypeOf(v) !== Array.prototype) { unsupported = true; return false; }
67
+ if (v.length === 0) return add(2);
68
+ const pad = 1 + (depth + 1) * 2; // "\n" + the member indent
69
+ if (!add(2 + (v.length - 1) + v.length * pad + 1 + depth * 2)) return false; // [ ] , between members, the lines
70
+ for (let i = 0; i < v.length; i++) {
71
+ const item: unknown = v[i];
72
+ if (isOmitted(item)) { if (!add(4)) return false; } else if (!walk(item, depth + 1)) return false;
73
+ }
74
+ return true;
75
+ }
76
+ const proto: unknown = Object.getPrototypeOf(v);
77
+ if (proto !== Object.prototype && proto !== null) { unsupported = true; return false; }
78
+ const rec = v as Record<string, unknown>;
79
+ if (typeof rec["toJSON"] === "function") { unsupported = true; return false; }
80
+ let members = 0;
81
+ const pad = 1 + (depth + 1) * 2;
82
+ if (!add(2)) return false; // { }
83
+ for (const key of Object.keys(rec)) {
84
+ const member = rec[key];
85
+ if (isOmitted(member)) continue;
86
+ // "\n" + indent, the quoted key, ": ", and a "," before every member but the first
87
+ if (!add(pad + quotedLength(key) + 2 + (members ? 1 : 0))) return false;
88
+ members++;
89
+ if (!walk(member, depth + 1)) return false;
90
+ }
91
+ if (members) return add(1 + depth * 2); // the closing "\n" + indent
92
+ return true;
93
+ };
94
+
95
+ const done = walk(value, 0);
96
+ if (unsupported) return { kind: "unsupported" };
97
+ return done ? { kind: "fits", length: total } : { kind: "over" };
98
+ }
99
+
100
+ /** `JSON.stringify(value, null, 2)` when its text is at most `limit` characters, else null — the same answer as
101
+ * stringifying first and comparing `.length`, without serialising a value that is over the limit. */
102
+ export function prettyJsonWithin(value: unknown, limit: number): string | null {
103
+ const size = prettyJsonSize(value, limit);
104
+ if (size.kind === "over") return null;
105
+ const text = JSON.stringify(value, null, 2);
106
+ // Unsupported shapes were not measured: the text is the measure.
107
+ return size.kind === "fits" || text.length <= limit ? text : null;
108
+ }