@design-twin/dtwin 0.1.0-alpha.0 → 0.1.1

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/src/init.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // Rules it keeps:
10
10
  // - Never overwrites. Every file it would write is skipped, and said so, if it already exists;
11
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,
12
+ // an entry of OURS that can no longer start (mcpEntryProblems: a bun or figma-mcp.ts that is gone or spelt through a versioned Homebrew Cellar folder,
13
13
  // a Node-era `node …/dist/figma-mcp.js`, bun without its two flags, a `bunx @design-twin/dtwin mcp` that fetches the
14
14
  // package at start-up): --mcp re-points that entry, under
15
15
  // its own key and keeping its other fields, at this install — and says so (--dry-run only says so).
@@ -106,8 +106,21 @@ const BUN_FLAGS = ["--no-install", "--no-env-file"] as const;
106
106
  * process.execPath alone is the resolved file, and for a Homebrew (or any versioned) install that is a versioned path
107
107
  * (…/Cellar/bun/1.4.2/bin/bun) the next `brew upgrade` deletes, leaving Claude Code a server it cannot start. */
108
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; }
109
+ if (!onPath) return stableKegPath(execPath);
110
+ try { return fs.realpathSync(onPath) === fs.realpathSync(execPath) ? onPath : stableKegPath(execPath); } catch { return stableKegPath(execPath); }
111
+ }
112
+
113
+ /** The upgrade-proof spelling of a path inside a Homebrew keg. Homebrew installs a formula into a versioned folder,
114
+ * `<prefix>/Cellar/<formula>/<version>/…`, that the next `brew upgrade` deletes, and keeps `<prefix>/opt/<formula>` as a
115
+ * symlink to whichever version is current. Bun resolves symlinks, so a file run through that opt link still sees its
116
+ * Cellar path (import.meta.dirname, process.execPath) — and an absolute path written from it (.mcp.json's server and
117
+ * bun, the plugin manifest Figma remembers) would stop working after one upgrade. Returns the opt spelling when it
118
+ * resolves to the very same file, else `p` unchanged (not in a Cellar, or a layout where opt is elsewhere). */
119
+ function stableKegPath(p: string): string {
120
+ const m = /^(.*)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+(?:[\\/](.*))?$/.exec(p);
121
+ if (!m || m[1] === undefined || m[2] === undefined) return p;
122
+ const opt = path.join(m[1], "opt", m[2], m[3] ?? "");
123
+ try { return fs.realpathSync(opt) === fs.realpathSync(p) ? opt : p; } catch { return p; }
111
124
  }
112
125
 
113
126
  /** The bunx cache directory `dir` sits in, or null. `bunx @design-twin/dtwin …` installs the package into a cache tree
@@ -119,7 +132,7 @@ function bunxCacheOf(dir: string): string | null {
119
132
  }
120
133
  const bunxRefusal = (cache: string): string =>
121
134
  `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 (in a clone: `bun install`, then `cd bridge && bun link`), and run `dtwin init --mcp` from that install";
135
+ "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
136
 
124
137
  /** How to look at the machine (seams for the suites): does a path exist, and what does a bare command resolve to. */
125
138
  export interface EntryProbe {
@@ -129,9 +142,16 @@ export interface EntryProbe {
129
142
  cwd?: string;
130
143
  }
131
144
 
145
+ /** A path that works today but sits in a versioned Homebrew folder the next `brew upgrade` deletes — when the opt
146
+ * spelling of it exists (stableKegPath), so init's repair has somewhere stable to point. Empty otherwise. */
147
+ function kegProblem(what: string, p: string): string[] {
148
+ const stable = stableKegPath(p);
149
+ return stable === p ? [] : [`${what} ${p} is in a versioned Homebrew folder the next \`brew upgrade\` deletes (the stable spelling is ${stable})`];
150
+ }
151
+
132
152
  /** What is wrong with an .mcp.json entry of OURS (isOurMcpEntry), one phrase each — empty when it will start: its command
133
153
  * 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). */
154
+ * fetch), that script exists outside a bunx cache, neither is spelt through a versioned Homebrew Cellar folder, and bun gets the two flags before that script. The ONE rule for init (repair it) and doctor (report it). */
135
155
  function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => Bun.which(c), cwd = process.cwd() }: EntryProbe = {}): string[] {
136
156
  if (!isRecord(e) || typeof e.command !== "string" || e.command === "") return ["it has no `command`"];
137
157
  const command = e.command;
@@ -142,6 +162,7 @@ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => B
142
162
  if (nodeShaped) out.push(`it runs on Node (\`${command}\`) — Design Twin runs on Bun only`);
143
163
  const found = /[\\/]/.test(command) ? exists(path.resolve(cwd, command)) : which(command) !== null;
144
164
  if (!found) out.push(`its command \`${command}\` ${/[\\/]/.test(command) ? "does not exist" : "is not on PATH"}`);
165
+ else if (/[\\/]/.test(command)) out.push(...kegProblem("its command", path.resolve(cwd, command)));
145
166
  const i = args.findIndex((a) => FIGMA_MCP_SCRIPT.test(a));
146
167
  const script = args[i];
147
168
  // No server script: the package-runner form (`bunx @design-twin/dtwin mcp`, `bun x @design-twin/dtwin mcp`, an older `npx designtwin
@@ -156,6 +177,7 @@ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => B
156
177
  // figma-mcp.js / .mjs (bridge/dist, or the older bridge/figma-mcp.mjs) is the Node-era build: the package ships .ts now
157
178
  if (/\.m?js$/.test(script)) out.push(`it points at the old Node build (${script})`);
158
179
  else if (!exists(abs)) out.push(`its server script ${script} does not exist`);
180
+ else out.push(...kegProblem("its server script", abs));
159
181
  const cache = bunxCacheOf(abs);
160
182
  if (cache) out.push(`its server script is in a bunx cache (${cache}) that Bun replaces or deletes on its own`);
161
183
  if (!nodeShaped) {
@@ -367,18 +389,21 @@ function main(argv: string[]): void {
367
389
  // ran init is one this package is known to work with, spelt the way that survives its upgrades
368
390
  // (stableBunPath). The flags are the shebang's (figma-pull.ts): no auto-install from the network, and
369
391
  // 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")] };
392
+ // Under Homebrew this file runs from a versioned Cellar folder, so the directory is spelt through opt (stableKegPath).
393
+ const here = stableKegPath(import.meta.dirname);
394
+ const mcpEntry: McpEntry = { command: stableBunPath(), args: [...BUN_FLAGS, path.join(here, "figma-mcp.ts")] };
371
395
  // Run through `bunx @design-twin/dtwin init`: this very file is in bunx's cache, so no entry may point at it.
372
396
  const cache = bunxCacheOf(import.meta.dirname);
373
397
  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 (in a clone: \`bun install\`, then \`cd bridge && bun link\`) to have \`dtwin\` on PATH and to register the MCP server`);
398
+ 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
399
  const actions = plan(cwd, { mcp: argv.includes("--mcp"), mcpEntry, ...ifDefined("refuseMcp", refuseMcp), token });
376
400
  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
401
  else apply(cwd, actions, log);
378
402
 
379
- // The plugin manifest of a repo checkout: bridge/src/ -> ../../figma-plugin/. A package
380
- // install has no figma-plugin/ beside it, so the step below falls back to naming the repo instead.
381
- const manifest = path.join(import.meta.dirname, "..", "..", "figma-plugin", "manifest.json");
403
+ // The Figma plugin's manifest: a repo checkout's (bridge/src/ -> ../../figma-plugin/), else the copy the npm
404
+ // package carries (../figma-plugin/, put there by bridge/pack.ts). Neither: the step names the repo instead.
405
+ // Spelt from `here` too: Figma remembers the path a plugin was imported from, and a Cellar one is gone after an upgrade.
406
+ const manifest = [path.join(here, "..", "..", "figma-plugin", "manifest.json"), path.join(here, "..", "figma-plugin", "manifest.json")].find((m) => fs.existsSync(m));
382
407
  // Step 3 is already done for anyone who reached init THROUGH the installed plugin's help skill —
383
408
  // which is the documented path. Claude Code records installed plugins under ~/.claude; when the
384
409
  // marker is there, saying "install the plugin" is noise at best and confusing at worst.
@@ -400,7 +425,7 @@ function main(argv: string[]): void {
400
425
  })();
401
426
 
402
427
  const steps = [
403
- "In Figma DESKTOP: Plugins → Development → Import plugin from manifest… → " + (fs.existsSync(manifest) ? manifest : "figma-plugin/manifest.json from a clone of the Design Twin repo (the npm package does not include it)"),
428
+ "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
429
  "Run the plugin (Plugins → Development → Design Twin), paste the bridge token into its \"Bridge token\" field, Save: dtwin --show-token | pbcopy",
405
430
  ];
406
431
  if (!pluginInstalled) {
@@ -421,4 +446,4 @@ function main(argv: string[]): void {
421
446
  );
422
447
  }
423
448
 
424
- export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };
449
+ export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, stableKegPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };