@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.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- 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;
|
package/src/json-file.ts
ADDED
|
@@ -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
|
+
}
|
package/src/json-size.ts
ADDED
|
@@ -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
|
+
}
|