@design-twin/dtwin 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,9 +14,18 @@ the package, `@design-twin/dtwin`, from npm:
14
14
  bun add -g @design-twin/dtwin # puts `dtwin` in ~/.bun/bin (on your PATH via the Bun installer)
15
15
  ```
16
16
  `npm i -g @design-twin/dtwin` installs the same thing, but `dtwin` still runs on Bun: without Bun on PATH it stops at
17
- `env: bun: No such file or directory`. The package also carries the built Figma plugin (`figma-plugin/manifest.json`
18
- inside it — `dtwin init` prints the full path to import) and the design-to-code bundles the MCP server's
19
- `design_get_component` / `design_drift_lint` load (`layer/`).
17
+ `env: bun: No such file or directory`. The package also carries the built Figma plugin, which `dtwin plugin install`
18
+ (or `dtwin init`) copies to a per-user folder for you to import once — see [The Figma plugin: `dtwin plugin`](#the-figma-plugin-dtwin-plugin) —
19
+ and the design-to-code bundles the MCP server's `design_get_component` / `design_drift_lint` load (`layer/`).
20
+
21
+ Or with Homebrew (macOS, Linux), which installs Bun for you and keeps both up to date with `brew upgrade`:
22
+ ```
23
+ brew install its-magdy/tap/dtwin
24
+ ```
25
+ The formula installs the same npm package into Homebrew's prefix; every release updates it. Paths `dtwin init` writes
26
+ (the `.mcp.json` server entry) are spelt through `$(brew --prefix)/opt/dtwin/…`, which
27
+ survives an upgrade — not the versioned `Cellar/dtwin/<version>/` folder the next `brew upgrade` deletes. The Figma
28
+ plugin is imported from its per-user copy (`dtwin plugin`), which `brew upgrade` leaves where it is.
20
29
 
21
30
  Or from a clone (to work on the code):
22
31
  ```
@@ -35,11 +44,13 @@ The plugin manifest allows the bridge in **both** dev and published builds
35
44
  (`allowedDomains: ws://localhost:{8787,8788,8789}`). Those three ports are the only ones the plugin can dial — `FIGMA_BRIDGE_PORT` is
36
45
  validated against them and exits rather than binding a port nothing could ever reach.
37
46
 
38
- > **Re-import the plugin after its manifest changes — required for library reads.** The manifest declares
39
- > `"permissions": ["teamlibrary"]`, without which Figma denies the team-library APIs. A plugin
40
- > imported from an older manifest keeps running happily and simply returns **no libraries** — a silent
41
- > empty result, not an error. If `--list-libraries` / `figma_list_libraries` shows nothing, re-import
42
- > `figma-plugin/manifest.json` (Plugins → Development → Import plugin from manifest…) first.
47
+ > **Library reads need the manifest's permission.** The manifest declares `"permissions": ["teamlibrary"]`,
48
+ > without which Figma denies the team-library APIs. A plugin still running an older manifest keeps working
49
+ > and simply returns **no libraries** — a silent empty result, not an error. If `--list-libraries` /
50
+ > `figma_list_libraries` shows nothing, turn on Plugins → Development → **Hot reload plugin** and reopen the plugin;
51
+ > if it is still empty, re-import the manifest `dtwin plugin path` prints (Plugins → Development → Import plugin from
52
+ > manifest…). Updates need no re-import: `dtwin plugin update` (or any dtwin that starts a bridge) replaces the files
53
+ > in place, and with hot reload on, reopening the plugin runs them — a changed manifest's permissions included.
43
54
 
44
55
  **Languages:** the whole bridge is **TypeScript** (ESM) under `src/` — `figma-pull.ts` (read CLI),
45
56
  `server-core.ts` (WS core), `figma-mcp.ts` (MCP server), and the modules they share. From a checkout,
@@ -182,7 +193,7 @@ README documents the flags, and either spelling is fine everywhere:
182
193
  | `dtwin serve` · `stop` · `status` | `--serve` · `--stop` · `--daemon-status` |
183
194
  | `dtwin token` · `token show` · `token rotate` · `token forget` | `--token-status` · `--show-token` · `--rotate-token` · `--forget-token` |
184
195
  | `dtwin help` | `--help` |
185
- | `dtwin doctor` · `dtwin init` · `dtwin mcp` · `dtwin seed` | their own entry points (no flag form) |
196
+ | `dtwin doctor` · `dtwin init` · `dtwin mcp` · `dtwin seed` · `dtwin plugin` | their own entry points (no flag form) |
186
197
 
187
198
  Every verb has its own page: `dtwin <verb> --help` (e.g. `dtwin whoami --help`, `dtwin serve --help`) prints
188
199
  that verb's flags and exits without starting a bridge. `outDir` is **positional** (`dtwin pull my-dir`):
@@ -196,6 +207,38 @@ With no outDir the export lands in `design/export` — the only place doctor, cr
196
207
  build-screen read. Never pull into bare `design`: that writes a second, parallel export tree beside
197
208
  `design/export/` (dtwin warns when it sees one).
198
209
 
210
+ ## The Figma plugin: `dtwin plugin`
211
+
212
+ The plugin is not on Figma Community; it is imported into Figma **desktop** as a development plugin (Plugins →
213
+ Development → Import plugin from manifest…). Figma remembers the manifest's path, so the folder must not move — and a
214
+ path inside the npm package does (Bun's and npm's
215
+ global folders differ, a Node version manager gives each Node its own, a `bunx` cache comes and goes, an uninstall
216
+ deletes it). So the package's plugin is copied to one per-user folder, beside the bridge token, and imported from there
217
+ once:
218
+
219
+ ```
220
+ dtwin plugin install # copy it to <config dir>/figma-plugin and print the one-time import step (--open shows it)
221
+ dtwin plugin update # the same, then says to reopen the plugin (and when the manifest changed too)
222
+ dtwin plugin status # (default) installed where, and is it this dtwin's? (--json)
223
+ dtwin plugin path # the manifest to import, on stdout
224
+ dtwin plugin uninstall # remove the copied files (then remove the plugin in Figma: Plugins → Manage plugins)
225
+ ```
226
+
227
+ `<config dir>` is the bridge token's: `~/.config/design-twin` (or `$XDG_CONFIG_HOME/design-twin`), `%APPDATA%\design-twin`
228
+ on Windows, `DESIGNTWIN_CONFIG_DIR` overriding both. The folder is hidden in a file picker: on macOS press ⌘⇧G and paste
229
+ the path; on Windows paste it into the File name box. `dtwin init` installs (or refreshes) the copy and prints the same
230
+ step. Install writes only the files that differ, each one atomically, under a lock, and is safe to run any number of times.
231
+
232
+ **Updates need no command.** Once a copy is installed, every `dtwin` that starts a bridge (a command, `dtwin serve`, the
233
+ MCP server) first brings it up to its own package's files, so after `bun add -g @design-twin/dtwin@latest` you only
234
+ reopen the plugin — with Plugins → Development → **Hot reload plugin** on. With it on, reopening runs the replaced
235
+ `code.js` / `ui.html` and picks up a changed manifest's permissions too (seen in Figma desktop 126); with it off, Figma
236
+ kept running the code it had already loaded. An update whose manifest changed says so, with re-importing as the
237
+ fallback if a new permission still does not work. Nothing is created that you did not
238
+ install, the network is never touched, and a `dtwin` running from a **repo checkout** never refreshes the copy —
239
+ developers import `figma-plugin/manifest.json` from the checkout itself (`dtwin plugin path` prints that one there).
240
+ `dtwin doctor` reports the copy as its "Plugin files" line.
241
+
199
242
  ## Seed the component map from Code Connect: `dtwin seed`
200
243
 
201
244
  If the project you are building already has Code Connect files, they are the Figma-component →
@@ -811,13 +854,38 @@ execution**, unlike some community servers; a `fill` / `color` must be a hex col
811
854
  `#rrggbb` or `#rrggbbaa` with the `#`, never a name like `red` — and anything else is refused (by the
812
855
  schema here, and by the plugin for a write from another client) instead of painted as a wrong colour; `dryRun: true` returns a preview of what each op would
813
856
  create or overwrite without touching the file — there is no undo from the MCP side, so preview anything
814
- that overwrites; refused outright when the file is in read-only Dev Mode), and two that read the export already on disk and
857
+ that overwrites; refused outright when the file is in read-only Dev Mode — and **refused unless the designer has
858
+ ticked "Allow edits"** in the plugin window, see below), and two that read the export already on disk and
815
859
  need no Figma connection — `design_get_component` (one component's variant node trees) and
816
860
  `design_drift_lint` (the component map vs the export; see below). The frame-walking export tools accept opt-in read flags
817
861
  `css` / `measurements` / `pluginData` / `motion` / `sharedData`; `figma_export_design_system` doesn't
818
862
  take them (it never walks a node — see below) but DOES take `variantVisuals`, since the component
819
863
  catalog it builds is exactly what that flag enriches.
820
864
 
865
+ ### "Allow edits" — the plugin changes nothing until the designer opts in
866
+ The plugin window has an **Allow edits** checkbox, **off by default**. While it is off the plugin only reads:
867
+ every `figma_write` batch is refused whole, before any op runs, and the tool returns this error (nothing in the
868
+ file changed):
869
+
870
+ > Edits are off in the Design Twin plugin — tick 'Allow edits' in the plugin window, then retry. Nothing in the file was changed.
871
+
872
+ That is not something an agent can fix. **Ask the user to tick "Allow edits" in the Design Twin plugin window,
873
+ then retry the same call.** The plugin's main thread holds the setting and checks it on every write; nothing on
874
+ the bridge side can turn it on.
875
+
876
+ - **Dry run still works while it is off.** `dryRun: true` previews the batch as before and adds `editsAllowed`
877
+ (read from the plugin's `whoami`): `false` → a real write would be refused right now (the preview also carries
878
+ `wouldRefuse` with the sentence above); `true` → it would be applied; `null` → unknown (no plugin connected,
879
+ several connected with no `client`, or a plugin built before the opt-in). Check it before asking the user to
880
+ review a preview, so the request to tick the box comes in the same breath.
881
+ - **The setting is per user, for every file.** It is saved in Figma's `clientStorage`, which is already scoped
882
+ to one user and this plugin. It is not per file: the plugin cannot reliably name a file (`fileKey` is only for
883
+ private plugins, and file names repeat), and a per-file flag stored in the document would itself be an edit.
884
+ Untick it when you are done.
885
+ - **No write is silent.** Each applied batch (and each refused one) shows a Figma toast and a line in the plugin
886
+ window, e.g. "Design Twin applied 3 edits to this file."
887
+ - `dtwin whoami` prints the state (`edits: off — …` / `edits: ALLOWED — …`) and `editsAllowed` is in its JSON.
888
+
821
889
  ### `writeToDisk` — the export path that doesn't go through the context window
822
890
  Every export tool takes `writeToDisk: true` (plus an optional `outDir`). It writes the export through
823
891
  the same `write-out.ts` the CLI uses — same layout, by construction — and returns a **compact index**
@@ -792,6 +792,7 @@
792
792
  function missingWriteFields(op, fields) {
793
793
  return WRITE_OPS[op].required.filter((k) => fields[k] === void 0);
794
794
  }
795
+ var EDITS_OFF_MESSAGE = "Edits are off in the Design Twin plugin \u2014 tick 'Allow edits' in the plugin window, then retry. Nothing in the file was changed.";
795
796
  var COMMAND_TRAITS = {
796
797
  ping: { run: "identity", export: false },
797
798
  whoami: { run: "identity", export: false },
@@ -3847,6 +3848,35 @@
3847
3848
  return { designSystem, layersDoc, assets: r.assets.slice() };
3848
3849
  }
3849
3850
 
3851
+ // src/edits.ts
3852
+ var KEY = "allowEdits";
3853
+ var allowed = false;
3854
+ var loaded = (async () => {
3855
+ try {
3856
+ allowed = await figma.clientStorage.getAsync(KEY) === true;
3857
+ } catch (e) {
3858
+ console.error("[export] could not read the Allow edits setting; edits stay off:", e);
3859
+ allowed = false;
3860
+ }
3861
+ })();
3862
+ async function editsAllowed() {
3863
+ await loaded;
3864
+ return allowed;
3865
+ }
3866
+ function editsAllowedNow() {
3867
+ return allowed;
3868
+ }
3869
+ async function setEditsAllowed(on) {
3870
+ await loaded;
3871
+ allowed = on;
3872
+ try {
3873
+ await figma.clientStorage.setAsync(KEY, on);
3874
+ } catch (e) {
3875
+ console.error("[export] could not save the Allow edits setting:", e);
3876
+ }
3877
+ return allowed;
3878
+ }
3879
+
3850
3880
  // src/writes.ts
3851
3881
  function solidPaint(hex, what) {
3852
3882
  const c = parseHexColor(hex);
@@ -3868,11 +3898,17 @@
3868
3898
  if (p.type === "PAGE") await p.loadAsync();
3869
3899
  return p;
3870
3900
  }
3901
+ var EditsRevoked = class extends Error {
3902
+ };
3903
+ function consent() {
3904
+ if (!editsAllowedNow()) throw new EditsRevoked("'Allow edits' was turned off in the Design Twin plugin partway through this batch \u2014 the ops before this one were applied. Tick 'Allow edits' in the plugin window, then retry the rest.");
3905
+ }
3871
3906
  async function applyWrite(op) {
3872
3907
  switch (op.op) {
3873
3908
  case "createFrame": {
3874
3909
  const parent = await resolveParent(op.parentId);
3875
3910
  const fill = op.fill ? solidPaint(op.fill, "createFrame fill") : null;
3911
+ consent();
3876
3912
  const f = figma.createFrame();
3877
3913
  if (op.name) f.name = op.name;
3878
3914
  if (op.width && op.height) f.resize(op.width, op.height);
@@ -3891,8 +3927,13 @@
3891
3927
  case "createText": {
3892
3928
  const parent = await resolveParent(op.parentId);
3893
3929
  const fill = op.fill ? solidPaint(op.fill, "createText fill") : null;
3930
+ consent();
3894
3931
  const t = figma.createText();
3895
3932
  await figma.loadFontAsync(t.fontName);
3933
+ if (!editsAllowedNow()) {
3934
+ t.remove();
3935
+ consent();
3936
+ }
3896
3937
  t.characters = op.text || "";
3897
3938
  if (op.fontSize) t.fontSize = op.fontSize;
3898
3939
  if (fill) t.fills = [fill];
@@ -3907,7 +3948,9 @@
3907
3948
  const nd = op.nodeId ? await figma.getNodeByIdAsync(op.nodeId) : null;
3908
3949
  if (!nd) throw new Error("setFill: no node with id '" + op.nodeId + "' (invalid or removed)");
3909
3950
  if (!("fills" in nd)) throw new Error("setFill: node '" + nd.name + "' (" + nd.type + ") has no fills");
3910
- nd.fills = [solidPaint(op.color, "setFill color")];
3951
+ const paint = solidPaint(op.color, "setFill color");
3952
+ consent();
3953
+ nd.fills = [paint];
3911
3954
  return ifDefined("id", op.nodeId);
3912
3955
  }
3913
3956
  case "setText": {
@@ -3915,6 +3958,7 @@
3915
3958
  if (!nd) throw new Error("setText: no node with id '" + op.nodeId + "' (invalid or removed)");
3916
3959
  if (nd.type !== "TEXT") throw new Error("setText: node '" + nd.name + "' is a " + nd.type + ", not TEXT");
3917
3960
  await loadNodeFonts(nd);
3961
+ consent();
3918
3962
  nd.characters = op.text || "";
3919
3963
  return ifDefined("id", op.nodeId);
3920
3964
  }
@@ -3923,8 +3967,31 @@
3923
3967
  }
3924
3968
  }
3925
3969
  async function applyWrites(ops) {
3926
- const list = Array.isArray(ops) ? ops : [];
3970
+ const r = await gatedWrites(Array.isArray(ops) ? ops : []);
3971
+ announce(r);
3972
+ return r;
3973
+ }
3974
+ function announce(r) {
3975
+ const n = r.applied.length;
3976
+ const edits = n + " edit" + (n === 1 ? "" : "s");
3977
+ let text = null;
3978
+ if (r.refused === "edits-off") text = n ? "Design Twin applied " + edits + ", then stopped \u2014 'Allow edits' was turned off." : "Design Twin refused an edit request \u2014 'Allow edits' is off.";
3979
+ else if (r.ok && n) text = "Design Twin applied " + edits + " to this file.";
3980
+ else if (!r.ok && n) text = "Design Twin applied " + edits + ", then one failed: " + (r.error || "unknown error");
3981
+ if (text === null) return;
3982
+ try {
3983
+ figma.notify(text, r.ok ? void 0 : { error: true });
3984
+ } catch (e) {
3985
+ console.error("[export] figma.notify failed:", e);
3986
+ }
3987
+ const m = { type: "writes", applied: n, ok: r.ok, text };
3988
+ figma.ui.postMessage(m);
3989
+ }
3990
+ async function gatedWrites(list) {
3927
3991
  const applied = [];
3992
+ if (list.length && !await editsAllowed()) {
3993
+ return { ok: false, applied, refused: "edits-off", error: EDITS_OFF_MESSAGE };
3994
+ }
3928
3995
  if (list.length && figma.editorType === "dev") {
3929
3996
  const first2 = list[0];
3930
3997
  return {
@@ -3948,7 +4015,8 @@
3948
4015
  applied,
3949
4016
  failedAt: i,
3950
4017
  failedOp: op && op.op,
3951
- error: errMsg(e)
4018
+ error: errMsg(e),
4019
+ ...e instanceof EditsRevoked ? { refused: "edits-off" } : {}
3952
4020
  };
3953
4021
  }
3954
4022
  }
@@ -4001,9 +4069,11 @@
4001
4069
  // freshly reloaded one, since neither startedAt nor code.js's mtime can. Reused verbatim by
4002
4070
  // the `hello` announcement below (main.ts's get-identity -> ui.html -> bridge), so `whoami`,
4003
4071
  // `dtwin list clients` and `dtwin doctor` can never disagree about which build is running.
4004
- pluginVersion: "0.1.0",
4072
+ pluginVersion: "0.1.2",
4005
4073
  fileKey: fileKey === void 0 ? null : fileKey,
4006
- fileKeyAvailable: typeof fileKey === "string" && fileKey.length > 0
4074
+ fileKeyAvailable: typeof fileKey === "string" && fileKey.length > 0,
4075
+ // So figma_write's dry run can say a real write would be refused before anyone sends one.
4076
+ editsAllowed: await editsAllowed()
4007
4077
  };
4008
4078
  return r;
4009
4079
  };
@@ -4065,6 +4135,8 @@
4065
4135
  // cost class as listPages depth 2, not that of an export.
4066
4136
  case "listLibraries":
4067
4137
  return () => listLibraries();
4138
+ // applyWrites refuses the batch whole while "Allow edits" is off (edits.ts) — the gate lives there, not
4139
+ // here, so no caller of it can skip it.
4068
4140
  case "write": {
4069
4141
  const ops = req.args.ops;
4070
4142
  return () => applyWrites(ops);
@@ -4083,6 +4155,8 @@
4083
4155
  "get-identity",
4084
4156
  "run-selection",
4085
4157
  "run-full",
4158
+ "get-allow-edits",
4159
+ "set-allow-edits",
4086
4160
  "cancel",
4087
4161
  "bridge"
4088
4162
  ]);
@@ -4159,6 +4233,14 @@
4159
4233
  post2({ type: "identity", identity });
4160
4234
  break;
4161
4235
  }
4236
+ case "get-allow-edits": {
4237
+ post2({ type: "allow-edits", allowed: await editsAllowed() });
4238
+ break;
4239
+ }
4240
+ case "set-allow-edits": {
4241
+ post2({ type: "allow-edits", allowed: await setEditsAllowed(raw.allowed === true) });
4242
+ break;
4243
+ }
4162
4244
  case "run-selection": {
4163
4245
  await runSelection();
4164
4246
  break;
@@ -60,6 +60,13 @@
60
60
  have to look ALIVE — a bar frozen at 0% reads as a hang. */
61
61
  #progress.indet #bar { width: 30%; animation: indet 1.2s linear infinite; }
62
62
  @keyframes indet { from { margin-left: -30%; } to { margin-left: 100%; } }
63
+ /* "Allow edits": always visible (never inside the collapsed connection section), because it is the one
64
+ control an agent will ask the designer to find. */
65
+ #edits-row { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 6px 0; }
66
+ #edits-row input { flex: 0 0 auto; width: 14px; height: 14px; margin: 0; padding: 0; }
67
+ #edits-state { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 9px;
68
+ background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-tertiary, #888); }
69
+ #edits-state.on { background: var(--figma-color-bg-warning-tertiary, #fff4e0); color: var(--figma-color-text-warning, #8a5a00); }
63
70
  #cancel { border-color: var(--figma-color-border-danger-strong, #c0362c); color: var(--figma-color-text-danger, #c0362c); }
64
71
  </style>
65
72
 
@@ -85,6 +92,17 @@
85
92
 
86
93
  <div class="hint">Save the downloads into your project's <code>design/</code> folder (assets into <code>design/assets/</code>).</div>
87
94
 
95
+ <div class="sep"></div>
96
+ <div class="label">Edits</div>
97
+ <!-- Off by default. While it is off the plugin only READS this file: every edit request from the bridge is
98
+ refused before anything changes (figma-plugin/src/edits.ts is the authority, not this checkbox). -->
99
+ <label id="edits-row" for="allow-edits">
100
+ <input id="allow-edits" type="checkbox" disabled />
101
+ <span>Allow edits</span>
102
+ <span id="edits-state">off</span>
103
+ </label>
104
+ <div class="hint" id="edits-hint" style="margin-top:2px">Lets Claude Code change this file over the bridge (add frames and text, set fills and text). Off: it can only read. Every change is announced here.</div>
105
+
88
106
  <div class="sep"></div>
89
107
  <!-- Optional. Closed by default; opened automatically only when it needs the user (a rejected token). -->
90
108
  <details id="connect">
@@ -307,9 +325,9 @@ var DesignTwinRelay = (() => {
307
325
  var p = $("progress"); if (p) p.hidden = false;
308
326
  setProgress(null);
309
327
  setBusy(true);
310
- setStatus(info.source === "bridge"
311
- ? "Export in progress — requested over the bridge (" + info.label + ")…"
312
- : "Exporting…");
328
+ setStatus(info.source !== "bridge" ? "Exporting…"
329
+ : info.label === "write" ? "Applying edits requested over the bridge…"
330
+ : "Export in progress — requested over the bridge (" + info.label + ")…");
313
331
  }
314
332
  // Tear the run chrome down. Deliberately does NOT clear `cancelSent`: the main thread sends
315
333
  // run-end BEFORE the error that the cancellation produced, and both need to know it was a cancel.
@@ -452,6 +470,18 @@ var DesignTwinRelay = (() => {
452
470
  syncBatchButtons();
453
471
  return;
454
472
  }
473
+ if (m.type === "allow-edits") {
474
+ // The main thread's answer is the truth (edits.ts): the checkbox only ever shows what it holds.
475
+ var ae = $("allow-edits"); if (ae) { ae.checked = !!m.allowed; ae.disabled = false; }
476
+ var es = $("edits-state"); if (es) { es.textContent = m.allowed ? "on" : "off"; es.className = m.allowed ? "on" : ""; }
477
+ return;
478
+ }
479
+ if (m.type === "writes") {
480
+ // A write batch changed the file (or was refused): never silent. It arrives inside the write's own run
481
+ // (run-begin … run-end, and run-end leaves the status alone), so it replaces that run's line.
482
+ setStatus(m.text, !m.ok);
483
+ return;
484
+ }
455
485
  if (m.type === "token") {
456
486
  bridgeToken = m.token || "";
457
487
  var t = $("token"); if (t) t.value = bridgeToken;
@@ -672,6 +702,12 @@ var DesignTwinRelay = (() => {
672
702
  return a.text != null ? new Blob([a.text], { type: type }) : new Blob([b64ToBytes(a.base64)], { type: type });
673
703
  });
674
704
  };
705
+ var ae = $("allow-edits"); if (ae) ae.onchange = function () {
706
+ // Disabled until the main thread confirms, so a double toggle cannot race its own answer.
707
+ ae.disabled = true;
708
+ parent.postMessage({ pluginMessage: { type: "set-allow-edits", allowed: !!ae.checked } }, "*");
709
+ };
710
+ parent.postMessage({ pluginMessage: { type: "get-allow-edits" } }, "*");
675
711
  // Ask code.js for the stored token; connect() runs once it replies. Also proves the round-trip.
676
712
  parent.postMessage({ pluginMessage: { type: "get-token" } }, "*");
677
713
  setStatus("Ready — select a frame, or use the blue button.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-twin/dtwin",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Design Twin bridge: local CLI + MCP server between Claude Code and the Design Twin Figma plugin. Reads a design on the free Figma plan, fully offline.",
5
5
  "license": "MIT",
6
6
  "author": "Mohamed Magdy Omar",
@@ -30,6 +30,7 @@ import { commitToken, daemonAddressOf, humanMs } from "./settings.ts";
30
30
  import type { BridgeSettings } from "./settings.ts";
31
31
  import type { Bridge, BridgeOptions } from "./server-core.ts";
32
32
  import { errMsg } from "./errmsg.ts";
33
+ import { refreshInstalledPlugin } from "./plugin-install.ts";
33
34
 
34
35
  export type Lifetime = "one-shot" | "serve" | "session";
35
36
 
@@ -131,6 +132,9 @@ export function bridgeHolder(lifetime: Lifetime, deps: HolderDeps): Holder {
131
132
  settings = c.settings;
132
133
  for (const line of c.notice) log(line);
133
134
  }
135
+ // About to hold the bridge, so no plugin is connected to this process yet: the moment to bring an installed
136
+ // per-user copy of the plugin up to this package's (plugin-install.ts — once per process, never from a checkout).
137
+ refreshInstalledPlugin(log);
134
138
  let failed: (e: Error) => void = () => {};
135
139
  const bindFailed = new Promise<never>((_, reject) => { failed = reject; });
136
140
  const b = deps.createBridge(settings, survive ? { onListenError: (e) => failed(e) } : {});
@@ -407,6 +407,7 @@ export const VERB_ROWS = [
407
407
  { word: "doctor", to: "routed", usage: " dtwin doctor [--wait N] [--json] # diagnose the setup; changes nothing. See doctor --help" },
408
408
  { word: "init", to: "routed", usage: " dtwin init [--mcp] [--dry-run] # set up the project you are building. See init --help" },
409
409
  { word: "seed", to: "routed", usage: " dtwin seed [codeRoot] [outDir] # components.json from your Code Connect files. See seed --help", help: SEED_HELP },
410
+ { word: "plugin", to: "routed", usage: " dtwin plugin [install|update|status|path] # the Figma plugin's per-user copy, to import once. See plugin --help" },
410
411
  { word: "mcp", to: "routed", usage: " dtwin mcp # run the MCP server (what .mcp.json points at). See mcp --help", help: MCP_HELP },
411
412
  { word: "help", to: "--help", usage: " dtwin help = --help" },
412
413
  {
package/src/commands.ts CHANGED
@@ -170,6 +170,9 @@ export interface WhoamiReply {
170
170
  /** null when `figma.fileKey` is unavailable (not a private plugin) */
171
171
  fileKey: string | null;
172
172
  fileKeyAvailable: boolean;
173
+ /** Whether the designer has ticked "Allow edits" in the plugin window (edits.ts). Absent from a plugin
174
+ * built before the opt-in existed — which applies every write it is sent. */
175
+ editsAllowed?: boolean;
173
176
  }
174
177
 
175
178
  /** One selected node (figma.currentPage.selection, summarised). */
@@ -285,13 +288,23 @@ export type ExportReply = FullExportReply | DesignSystemReply | ScreenReply | Sc
285
288
  export interface AppliedWrite {
286
289
  id?: string;
287
290
  }
288
- /** writes.ts applyWrites' reply: `ok: false` on a partial failure, with what was applied before it. */
291
+ /** Why a whole batch was refused before any op ran — the one refusal a caller must hand to the PERSON at
292
+ * the Figma window rather than fix itself. `edits-off`: the designer has not ticked "Allow edits"
293
+ * (figma-plugin/src/edits.ts); nothing in the file changed. */
294
+ export type WriteRefusal = "edits-off";
295
+ /** The sentence a refused `edits-off` batch carries as `error`, and what figma_write shows the agent. */
296
+ export const EDITS_OFF_MESSAGE =
297
+ "Edits are off in the Design Twin plugin — tick 'Allow edits' in the plugin window, then retry. Nothing in the file was changed.";
298
+ /** writes.ts applyWrites' reply: `ok: false` on a partial failure, with what was applied before it. A batch
299
+ * refused whole carries `refused` (and no `failedAt`: no op was tried); one stopped partway because the designer
300
+ * unticked "Allow edits" carries `refused` AND `failedAt`, with the applied prefix. */
289
301
  export interface WriteResult {
290
302
  ok: boolean;
291
303
  applied: AppliedWrite[];
292
304
  failedAt?: number;
293
305
  failedOp?: string;
294
306
  error?: string;
307
+ refused?: WriteRefusal;
295
308
  }
296
309
 
297
310
  // ---------------------------------------------------------------- the map
package/src/doctor.ts CHANGED
@@ -39,6 +39,8 @@ import { ifDefined } from "./json-util.ts";
39
39
  import * as LAYOUT from "./project-layout.ts";
40
40
  import { isOurMcpEntry, mcpEntryProblems, PROFILES, readMcpJson } from "./init.ts";
41
41
  import { daemonRowStalenessNote } from "./staleness.ts";
42
+ import { describeState, pluginState } from "./plugin-install.ts";
43
+ import type { PluginState } from "./plugin-install.ts";
42
44
  // The manifest's allowedDomains ports and the FIGMA_BRIDGE_PORT parse — ports.ts, the same list and
43
45
  // parse settings.ts reads the port with. A bad value is a finding here (the very case this exists to diagnose).
44
46
  import { ALLOWED_PORTS, parsePortEnv } from "./ports.ts";
@@ -51,7 +53,7 @@ import { exitCli } from "./stdio.ts";
51
53
  import { daemonAddressOf, readSettings, tokenSetting } from "./settings.ts";
52
54
 
53
55
  /** Every check this report can carry, by id — what `--json` consumers key on. */
54
- export type CheckId = "bun" | "token" | "daemon" | "port" | "plugin" | "project" | "profile" | "layout" | "export" | "map" | "mcp";
56
+ export type CheckId = "bun" | "token" | "daemon" | "port" | "plugin" | "plugin-files" | "project" | "profile" | "layout" | "export" | "map" | "mcp";
55
57
 
56
58
  /** One line of the report. `next` is the single next step (absent, or undefined, when there is none). `skipped`: the
57
59
  * check could not run (a warn), so nothing it would have verified was — the report is not ok beside one. */
@@ -327,6 +329,14 @@ function exportChecks(v: ExportVerdict, rel: string): Check[] {
327
329
  // custom stack written in design/profile.md), plus design/profile.md read after a bundled profile as the project's own
328
330
  // rules. An older project's root profiles/<profile>.md is read the same way.
329
331
  export const OVERLAY_FILE = path.join(LAYOUT.DESIGN_DIR, "profile.md");
332
+ // The plugin's files on disk (plugin-install.ts), not the running plugin: is there a per-user copy to import, and is it
333
+ // this dtwin's? Read only — doctor never installs or refreshes it. A note, never a failure: someone who imported from
334
+ // a clone or the package folder before is still working.
335
+ function checkPluginFiles(s: PluginState): Check {
336
+ const d = describeState(s);
337
+ return d.ok ? ok("plugin-files", "Plugin files", d.detail) : warn("plugin-files", "Plugin files", d.detail, d.next);
338
+ }
339
+
330
340
  function checkProfile(cwd: string): Check {
331
341
  let profile: string | null = null;
332
342
  try {
@@ -549,6 +559,7 @@ async function run({ cwd = process.cwd(), waitSec = 10, onCheck, onWait }: RunOp
549
559
  }
550
560
  }
551
561
  if (tok.shadowed) add(checkToken(tok, pluginCheck, viaDaemon));
562
+ add(checkPluginFiles(pluginState()));
552
563
 
553
564
  add(...checkProject(cwd));
554
565
  return { ok: reportOk(checks), checks };
@@ -599,4 +610,4 @@ async function main(argv: string[]): Promise<void> {
599
610
  process.exitCode = report.ok ? 0 : 1;
600
611
  }
601
612
 
602
- export { checkBun, checkToken, checkDaemon, sharedTokenNote, resolvePort, ephemeralPortWarning, checkPort, checkPlugin, checkProject, exportChecks, reportOk, probePort, probePlugin, run, main };
613
+ export { checkBun, checkToken, checkDaemon, sharedTokenNote, resolvePort, ephemeralPortWarning, checkPort, checkPlugin, checkPluginFiles, checkProject, exportChecks, reportOk, probePort, probePlugin, run, main };
package/src/figma-mcp.ts CHANGED
@@ -36,7 +36,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto
36
36
  import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
37
37
  // The command/reply contract shared with the plugin (commands.ts): every route request below is
38
38
  // typed per command from it, so a tool cannot read a reply field the plugin never sends.
39
- import { WRITE_OP_NAMES, writeOpPreview, type Cmd, type Commands, type ExportReply, type WriteOp } from "./commands.ts";
39
+ import { EDITS_OFF_MESSAGE, WRITE_OP_NAMES, writeOpPreview, type Cmd, type Commands, type ExportReply, type WriteOp } from "./commands.ts";
40
40
  // TIMEOUTS is the per-command budget table shared with the dtwin CLI. Every EXPORT tool needs a
41
41
  // budget well above server-core's 120s default (tuned for ping/getSelection, not for a walk of every
42
42
  // node on a page) — a whole-file walk is the slow case, but a single big frame is the same shape of
@@ -681,7 +681,10 @@ const writeOpShape = z.object({
681
681
  }).passthrough();
682
682
 
683
683
  // Each step is commands.ts writeOpPreview — the op's WRITE_OPS row, which the plugin's wire check (writeOpError) reads too.
684
- function previewWrites(ops: WriteOp[]) {
684
+ // `editsAllowed` is the plugin's "Allow edits" checkbox as its whoami reports it: false => a real write would be
685
+ // refused (and `wouldRefuse` says what to ask the designer); null => unknown (no plugin reachable, or one built
686
+ // before the opt-in).
687
+ function previewWrites(ops: WriteOp[], editsAllowed: boolean | null) {
685
688
  const steps = ops.map(writeOpPreview);
686
689
  return {
687
690
  dryRun: true,
@@ -690,10 +693,30 @@ function previewWrites(ops: WriteOp[]) {
690
693
  overwrites: steps.filter((s) => s.effect === "overwrites").length,
691
694
  invalid: steps.filter((s) => "invalid" in s).length,
692
695
  steps,
693
- note: "Nothing was changed. Overwrites cannot be undone from here (only Cmd-Z in Figma). Re-call without dryRun to apply.",
696
+ editsAllowed,
697
+ ...(editsAllowed === false ? { wouldRefuse: EDITS_OFF_MESSAGE } : {}),
698
+ note: "Nothing was changed. Overwrites cannot be undone from here (only Cmd-Z in Figma). " +
699
+ (editsAllowed === false
700
+ ? "A real write would be REFUSED right now: 'Allow edits' is off in the Design Twin plugin window — ask the designer to tick it, then re-call without dryRun."
701
+ : editsAllowed === null
702
+ ? "Could not read the plugin's 'Allow edits' setting (no single plugin reachable); a real write is refused while it is off. Re-call without dryRun to apply."
703
+ : "Re-call without dryRun to apply."),
694
704
  };
695
705
  }
696
706
 
707
+ // The dry run's read of the "Allow edits" checkbox. Never waits for a plugin to connect (the preview must answer
708
+ // with none): nothing connected, several connected with no `client` named, or any failure, is `null` (unknown).
709
+ async function readEditsAllowed(a: { client?: string | undefined } | undefined): Promise<boolean | null> {
710
+ try {
711
+ const n = (await route.listClients()).length;
712
+ if (n === 0 || (!(a && a.client) && n > 1)) return null;
713
+ const who = await ask("whoami", {}, { timeoutMs: TIMEOUTS.command, target: a && a.client });
714
+ return typeof who.editsAllowed === "boolean" ? who.editsAllowed : null;
715
+ } catch {
716
+ return null;
717
+ }
718
+ }
719
+
697
720
  server.registerTool(
698
721
  "figma_write",
699
722
  {
@@ -705,7 +728,10 @@ server.registerTool(
705
728
  "Ops are applied in order and are NOT transactional: if one fails, the earlier ones stay applied " +
706
729
  "and their node ids are reported alongside the error so you can continue or clean up. " +
707
730
  "There is no undo from here (only the designer's own Cmd-Z) — so for anything that OVERWRITES " +
708
- "(setFill/setText), call once with dryRun:true, show the user the preview, and only then apply.",
731
+ "(setFill/setText), call once with dryRun:true, show the user the preview, and only then apply. " +
732
+ "The plugin applies nothing unless the designer has ticked 'Allow edits' in its window (off by default); " +
733
+ "while it is off a write is refused with that instruction, and a dry run reports editsAllowed:false — " +
734
+ "ask the designer to tick it, then retry.",
709
735
  inputSchema: {
710
736
  ...clientShape,
711
737
  // Constrain `op` to the four ops the plugin actually implements — reject unknown ops at the
@@ -715,7 +741,7 @@ server.registerTool(
715
741
  // 50 ops is several screens' worth of frames/text, and a larger job is simply several calls.
716
742
  .max(50, "at most 50 ops per call — split a larger job into several calls (writes cannot be undone from here)")
717
743
  .describe("Ordered list of write operations (each must have an `op` field). At most 50 per call."),
718
- dryRun: z.boolean().optional().describe("true = validate the batch and return a preview of what each op would create/overwrite, WITHOUT touching the Figma file (needs no plugin connection)."),
744
+ dryRun: z.boolean().optional().describe("true = validate the batch and return a preview of what each op would create/overwrite, WITHOUT touching the Figma file (needs no plugin connection; when one is connected it also reports editsAllowed — false means a real write would be refused)."),
719
745
  },
720
746
  // destructiveHint MUST stay true. Per the MCP schema it asserts, when false, that "the tool
721
747
  // performs only additive updates" — but setFill/setText OVERWRITE properties on nodes the user
@@ -728,10 +754,13 @@ server.registerTool(
728
754
  guarded(async (a) => {
729
755
  // The preview is built HERE, not plugin-side: it must work before a connection exists, and the
730
756
  // annotations above are only a hint — not every MCP client turns destructiveHint into a prompt.
731
- if (a.dryRun) return textResult(previewWrites(a.ops || []));
757
+ if (a.dryRun) return textResult(previewWrites(a.ops || [], await readEditsAllowed(a)));
732
758
  // No cancel signal here (unlike the reads): a write cannot be un-applied, and aborting the wait would
733
759
  // only lose the `applied` ids below.
734
760
  const r = await ask("write", { ops: a.ops || [] }, { target: a && a.client });
761
+ // The designer has not consented: nothing was tried, so there is no op index and nothing to clean up. The
762
+ // fix is the PERSON's (a checkbox in the plugin window), so say exactly that rather than "write failed".
763
+ if (r.refused === "edits-off" && !r.applied.length) return errorResult(r.error || EDITS_OFF_MESSAGE);
735
764
  // Partial failure is reported as an error result, but MUST still carry `applied` — those nodes
736
765
  // exist in the document and the agent needs their ids to continue or undo.
737
766
  if (!r.ok) {
package/src/figma-pull.ts CHANGED
@@ -947,6 +947,11 @@ async function whoamiCmd({ route, send, finish }: Session): Promise<void> {
947
947
  console.error("[dtwin] plugin version: " + (r.pluginVersion || "unknown (older bundle — predates version reporting)"));
948
948
  const stale = pluginStalenessNote(r.pluginVersion);
949
949
  if (stale) console.error("[dtwin] ! " + stale);
950
+ console.error("[dtwin] edits: " + (r.editsAllowed === true
951
+ ? "ALLOWED — the designer ticked 'Allow edits' in the plugin window; figma_write can change this file"
952
+ : r.editsAllowed === false
953
+ ? "off — the plugin refuses every write until the designer ticks 'Allow edits' in the plugin window (reads are unaffected)"
954
+ : "unknown (older bundle — predates the 'Allow edits' opt-in, and applies every write)"));
950
955
  console.error("[dtwin] fileKey: " + (r.fileKeyAvailable
951
956
  ? r.fileKey + " (available — usable as a stable routing key)"
952
957
  : "UNAVAILABLE (gated to private plugins; routing must use a server-minted id)"));
@@ -1141,6 +1146,7 @@ const ROUTES = {
1141
1146
  init: async (args) => { (await import("./init.ts")).main(args); },
1142
1147
  doctor: async (args) => { await (await import("./doctor.ts")).main(args); },
1143
1148
  seed: async (args) => { (await import("./seed-components.ts")).main(args); },
1149
+ plugin: async (args) => { (await import("./plugin-install.ts")).main(args); },
1144
1150
  } satisfies Record<RoutedVerb, (args: string[]) => Promise<void>>;
1145
1151
  const routed = (word: string | undefined): word is RoutedVerb => word !== undefined && Object.hasOwn(ROUTES, word);
1146
1152
 
@@ -1172,7 +1178,7 @@ async function runMcp(args: string[]): Promise<void> {
1172
1178
  // first, then --version/--help, then the arguments are parsed, THEN the settings are read (settings.ts — port, token
1173
1179
  // with --token-file, limits) and the command runs.
1174
1180
  async function cli(argv: string[]): Promise<void> {
1175
- // `dtwin mcp|init|doctor|seed` — their own entry points, before anything else runs (in particular before
1181
+ // `dtwin mcp|init|doctor|seed|plugin` — their own entry points, before anything else runs (in particular before
1176
1182
  // the settings are read): `dtwin mcp` is taken ONLY when the first argument is exactly "mcp", so `dtwin …` and
1177
1183
  // importing this module from the test suites are untouched, and `bun bridge/src/figma-mcp.ts` still works.
1178
1184
  const word = argv[0];
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).
@@ -31,6 +31,8 @@ import type { ResolvedToken } from "./token-store.ts";
31
31
  import * as LAYOUT from "./project-layout.ts";
32
32
  import { ifDefined, isRecord } from "./json-util.ts";
33
33
  import { exitCli } from "./stdio.ts";
34
+ import { errMsg } from "./errmsg.ts";
35
+ import { hiddenHint, installPlugin, nextSteps, pluginHome, pluginSource } from "./plugin-install.ts";
34
36
 
35
37
  /** One .mcp.json server entry, as init writes it. */
36
38
  export interface McpEntry {
@@ -106,8 +108,21 @@ const BUN_FLAGS = ["--no-install", "--no-env-file"] as const;
106
108
  * process.execPath alone is the resolved file, and for a Homebrew (or any versioned) install that is a versioned path
107
109
  * (…/Cellar/bun/1.4.2/bin/bun) the next `brew upgrade` deletes, leaving Claude Code a server it cannot start. */
108
110
  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
+ if (!onPath) return stableKegPath(execPath);
112
+ try { return fs.realpathSync(onPath) === fs.realpathSync(execPath) ? onPath : stableKegPath(execPath); } catch { return stableKegPath(execPath); }
113
+ }
114
+
115
+ /** The upgrade-proof spelling of a path inside a Homebrew keg. Homebrew installs a formula into a versioned folder,
116
+ * `<prefix>/Cellar/<formula>/<version>/…`, that the next `brew upgrade` deletes, and keeps `<prefix>/opt/<formula>` as a
117
+ * symlink to whichever version is current. Bun resolves symlinks, so a file run through that opt link still sees its
118
+ * Cellar path (import.meta.dirname, process.execPath) — and an absolute path written from it (.mcp.json's server and
119
+ * bun, the plugin manifest Figma remembers) would stop working after one upgrade. Returns the opt spelling when it
120
+ * resolves to the very same file, else `p` unchanged (not in a Cellar, or a layout where opt is elsewhere). */
121
+ function stableKegPath(p: string): string {
122
+ const m = /^(.*)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+(?:[\\/](.*))?$/.exec(p);
123
+ if (!m || m[1] === undefined || m[2] === undefined) return p;
124
+ const opt = path.join(m[1], "opt", m[2], m[3] ?? "");
125
+ try { return fs.realpathSync(opt) === fs.realpathSync(p) ? opt : p; } catch { return p; }
111
126
  }
112
127
 
113
128
  /** The bunx cache directory `dir` sits in, or null. `bunx @design-twin/dtwin …` installs the package into a cache tree
@@ -129,9 +144,16 @@ export interface EntryProbe {
129
144
  cwd?: string;
130
145
  }
131
146
 
147
+ /** A path that works today but sits in a versioned Homebrew folder the next `brew upgrade` deletes — when the opt
148
+ * spelling of it exists (stableKegPath), so init's repair has somewhere stable to point. Empty otherwise. */
149
+ function kegProblem(what: string, p: string): string[] {
150
+ const stable = stableKegPath(p);
151
+ return stable === p ? [] : [`${what} ${p} is in a versioned Homebrew folder the next \`brew upgrade\` deletes (the stable spelling is ${stable})`];
152
+ }
153
+
132
154
  /** What is wrong with an .mcp.json entry of OURS (isOurMcpEntry), one phrase each — empty when it will start: its command
133
155
  * 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). */
156
+ * 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
157
  function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => Bun.which(c), cwd = process.cwd() }: EntryProbe = {}): string[] {
136
158
  if (!isRecord(e) || typeof e.command !== "string" || e.command === "") return ["it has no `command`"];
137
159
  const command = e.command;
@@ -142,6 +164,7 @@ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => B
142
164
  if (nodeShaped) out.push(`it runs on Node (\`${command}\`) — Design Twin runs on Bun only`);
143
165
  const found = /[\\/]/.test(command) ? exists(path.resolve(cwd, command)) : which(command) !== null;
144
166
  if (!found) out.push(`its command \`${command}\` ${/[\\/]/.test(command) ? "does not exist" : "is not on PATH"}`);
167
+ else if (/[\\/]/.test(command)) out.push(...kegProblem("its command", path.resolve(cwd, command)));
145
168
  const i = args.findIndex((a) => FIGMA_MCP_SCRIPT.test(a));
146
169
  const script = args[i];
147
170
  // No server script: the package-runner form (`bunx @design-twin/dtwin mcp`, `bun x @design-twin/dtwin mcp`, an older `npx designtwin
@@ -156,6 +179,7 @@ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => B
156
179
  // figma-mcp.js / .mjs (bridge/dist, or the older bridge/figma-mcp.mjs) is the Node-era build: the package ships .ts now
157
180
  if (/\.m?js$/.test(script)) out.push(`it points at the old Node build (${script})`);
158
181
  else if (!exists(abs)) out.push(`its server script ${script} does not exist`);
182
+ else out.push(...kegProblem("its server script", abs));
159
183
  const cache = bunxCacheOf(abs);
160
184
  if (cache) out.push(`its server script is in a bunx cache (${cache}) that Bun replaces or deletes on its own`);
161
185
  if (!nodeShaped) {
@@ -367,7 +391,9 @@ function main(argv: string[]): void {
367
391
  // ran init is one this package is known to work with, spelt the way that survives its upgrades
368
392
  // (stableBunPath). The flags are the shebang's (figma-pull.ts): no auto-install from the network, and
369
393
  // 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")] };
394
+ // Under Homebrew this file runs from a versioned Cellar folder, so the directory is spelt through opt (stableKegPath).
395
+ const here = stableKegPath(import.meta.dirname);
396
+ const mcpEntry: McpEntry = { command: stableBunPath(), args: [...BUN_FLAGS, path.join(here, "figma-mcp.ts")] };
371
397
  // Run through `bunx @design-twin/dtwin init`: this very file is in bunx's cache, so no entry may point at it.
372
398
  const cache = bunxCacheOf(import.meta.dirname);
373
399
  const refuseMcp = cache ? bunxRefusal(cache) : undefined;
@@ -376,9 +402,25 @@ function main(argv: string[]): void {
376
402
  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
403
  else apply(cwd, actions, log);
378
404
 
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));
405
+ // The Figma plugin's manifest: a repo checkout's own figma-plugin/, else the per-user copy (plugin-install.ts),
406
+ // installed — or refreshed — here from the files the npm package carries, so the path to import never moves.
407
+ // Neither: the step names the repo instead.
408
+ const manifest = ((): string | null => {
409
+ const source = pluginSource();
410
+ if (!source) return null;
411
+ if (source.kind === "checkout") return path.join(source.dir, "manifest.json");
412
+ if (dry) { log(`would install the Figma plugin to ${pluginHome()} (dtwin plugin install)`); return path.join(pluginHome(), "manifest.json"); }
413
+ try {
414
+ const r = installPlugin(source);
415
+ log(`plugin Figma plugin v${r.version ?? "?"} ${r.status === "current" ? "already up to date" : r.status} at ${r.home}`);
416
+ if (r.status !== "installed") for (const s of nextSteps(r)) log(" " + s);
417
+ return r.manifest;
418
+ } catch (e) {
419
+ log(`warn could not install the Figma plugin to ${pluginHome()} (${errMsg(e)}); import the package's own copy instead`);
420
+ // Spelt through opt under Homebrew: Figma remembers the path, and a Cellar one is gone after an upgrade.
421
+ return path.join(stableKegPath(source.dir), "manifest.json");
422
+ }
423
+ })();
382
424
  // Step 3 is already done for anyone who reached init THROUGH the installed plugin's help skill —
383
425
  // which is the documented path. Claude Code records installed plugins under ~/.claude; when the
384
426
  // marker is there, saying "install the plugin" is noise at best and confusing at worst.
@@ -400,7 +442,8 @@ function main(argv: string[]): void {
400
442
  })();
401
443
 
402
444
  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)"),
445
+ "In Figma DESKTOP, once: 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)") +
446
+ (manifest && hiddenHint(manifest) ? ` (${hiddenHint(manifest) ?? ""})` : ""),
404
447
  "Run the plugin (Plugins → Development → Design Twin), paste the bridge token into its \"Bridge token\" field, Save: dtwin --show-token | pbcopy",
405
448
  ];
406
449
  if (!pluginInstalled) {
@@ -421,4 +464,4 @@ function main(argv: string[]): void {
421
464
  );
422
465
  }
423
466
 
424
- export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };
467
+ export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, stableKegPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };
@@ -0,0 +1,285 @@
1
+ // plugin-install.ts — the Figma plugin's per-user copy, and `dtwin plugin install|update|status|path|uninstall`.
2
+ //
3
+ // The plugin cannot be listed on Figma Community, so it is imported as a development plugin: Plugins → Development →
4
+ // Import plugin from manifest…. Figma remembers the manifest's PATH, so the folder it was imported from must not move —
5
+ // and then replacing the files there is the whole update. With Plugins → Development → Hot reload plugin ON,
6
+ // reopening the plugin runs the new code and picks up a changed manifest's permissions too (seen live, Figma
7
+ // desktop 126); with it OFF, Figma kept running the code it had loaded. A path inside the npm package does move (Bun's and npm's global
8
+ // folders differ, a Node version manager gives every Node its own prefix, a bunx cache is replaced whenever Bun likes,
9
+ // an uninstall deletes it), so an install copies the plugin to ONE per-user folder, next to the bridge token:
10
+ // <configDir>/figma-plugin (token-store.ts: ~/.config/design-twin, %APPDATA%\design-twin on Windows,
11
+ // DESIGNTWIN_CONFIG_DIR overriding both). That is the path to import, once.
12
+ //
13
+ // A copy whose manifest.json changed (permissions, networkAccess) says so, with re-importing as the fallback for when
14
+ // reopening does not pick the change up.
15
+ //
16
+ // The copy is refreshed — never created — whenever this process is about to bind a bridge (refreshInstalledPlugin,
17
+ // from bridge-holder.ts): an upgraded package brings its plugin along with no command to remember, and at that moment
18
+ // no plugin is connected to this process, so no export of ours is mid-run when the files change. A dtwin running from
19
+ // a repo checkout never refreshes it: a developer imports figma-plugin/ itself, and their copy is theirs.
20
+ //
21
+ // Each file is written atomically (atomic-write.ts: a tmp file and a rename — the folder itself is never swapped,
22
+ // since the path Figma holds must stay the one it imported), ui.html and code.js before manifest.json, install.json
23
+ // last, all under one lock (file-lock.ts), so two processes refreshing at once write each file once and a reader
24
+ // never sees half a file.
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { spawn } from "node:child_process";
28
+ import { writeFileAtomic } from "./atomic-write.ts";
29
+ import { errMsg } from "./errmsg.ts";
30
+ import { withFileLock } from "./file-lock.ts";
31
+ import { sha256Hex } from "./hash.ts";
32
+ import { BRIDGE_VERSION } from "./staleness.ts";
33
+ import { exitCli } from "./stdio.ts";
34
+ import { configDir } from "./token-store.ts";
35
+
36
+ /** The files Figma loads: the manifest and the two it names (bridge/pack.ts ships exactly these). */
37
+ export const PLUGIN_FILES = ["manifest.json", "code.js", "ui.html"] as const;
38
+ type PluginFile = (typeof PLUGIN_FILES)[number];
39
+ // The manifest last: a manifest that names code.js / ui.html is never on disk before they are.
40
+ const WRITE_ORDER: readonly PluginFile[] = ["ui.html", "code.js", "manifest.json"];
41
+ const RECORD = "install.json";
42
+
43
+ /** Where the plugin is installed for this user: <configDir>/figma-plugin. */
44
+ export const pluginHome = (): string => path.join(configDir(), "figma-plugin");
45
+ const lockOf = (home: string): string => home + ".lock";
46
+
47
+ /** Where this dtwin's plugin files are: a repo checkout's figma-plugin/ (bridge/src/ → ../../figma-plugin/, told
48
+ * apart by the ui.template.html no package ships), else the copy the npm package carries (../figma-plugin/,
49
+ * bridge/pack.ts). null: neither (a package packed without it). */
50
+ export interface PluginSource { dir: string; kind: "checkout" | "package" }
51
+ export function pluginSource(here: string = import.meta.dirname): PluginSource | null {
52
+ const checkout = path.join(here, "..", "..", "figma-plugin");
53
+ if (fs.existsSync(path.join(checkout, "ui.template.html")) && fs.existsSync(path.join(checkout, "manifest.json"))) return { dir: checkout, kind: "checkout" };
54
+ const packaged = path.join(here, "..", "figma-plugin");
55
+ if (PLUGIN_FILES.every((f) => fs.existsSync(path.join(packaged, f)))) return { dir: packaged, kind: "package" };
56
+ return null;
57
+ }
58
+
59
+ /** What install.json records: the version installed, each file's sha256, from where, when. */
60
+ export interface InstallRecord { version: string | null; files: Record<string, string>; from: PluginSource["kind"]; installedAt: string }
61
+
62
+ function readRecord(home: string): InstallRecord | null {
63
+ try {
64
+ const r: unknown = JSON.parse(fs.readFileSync(path.join(home, RECORD), "utf8"));
65
+ if (typeof r !== "object" || r === null || !("files" in r) || typeof r.files !== "object" || r.files === null) return null;
66
+ return r as InstallRecord; // shape checked above; every other field is only printed
67
+ } catch { return null; }
68
+ }
69
+ const shaOf = (file: string): string | null => {
70
+ try { return sha256Hex(fs.readFileSync(file)); } catch { return null; }
71
+ };
72
+ const shas = (dir: string): Record<PluginFile, string | null> =>
73
+ Object.fromEntries(PLUGIN_FILES.map((f) => [f, shaOf(path.join(dir, f))])) as Record<PluginFile, string | null>;
74
+
75
+ /** The per-user copy as it is now, against this dtwin's own plugin files. */
76
+ export interface PluginState {
77
+ home: string;
78
+ /** the manifest to import into Figma: the checkout's own when this dtwin runs from one, else the installed copy's */
79
+ importPath: string;
80
+ source: PluginSource | null;
81
+ /** every plugin file is in the home folder */
82
+ installed: boolean;
83
+ record: InstallRecord | null;
84
+ /** the installed files are byte-for-byte this dtwin's (null: not installed, or nothing to compare with) */
85
+ current: boolean | null;
86
+ }
87
+ export function pluginState(source: PluginSource | null = pluginSource(), home: string = pluginHome()): PluginState {
88
+ const have = shas(home);
89
+ const installed = PLUGIN_FILES.every((f) => have[f] !== null);
90
+ const want = source ? shas(source.dir) : null;
91
+ const current = installed && want ? PLUGIN_FILES.every((f) => have[f] === want[f]) : null;
92
+ const importPath = source?.kind === "checkout" ? path.join(source.dir, "manifest.json") : path.join(home, "manifest.json");
93
+ return { home, importPath, source, installed, record: readRecord(home), current };
94
+ }
95
+
96
+ export interface InstallResult {
97
+ status: "installed" | "updated" | "current";
98
+ home: string;
99
+ manifest: string;
100
+ version: string | null;
101
+ /** the files rewritten, in the order written */
102
+ written: PluginFile[];
103
+ /** an earlier copy's manifest.json differed (permissions / network access): if reopening does not pick it up, re-import */
104
+ manifestChanged: boolean;
105
+ }
106
+
107
+ /** Copy `source`'s plugin files into `home`, writing only the ones that differ. Idempotent: an up-to-date copy is
108
+ * left untouched ("current"). */
109
+ export function installPlugin(source: PluginSource, home: string = pluginHome(), now: () => Date = () => new Date()): InstallResult {
110
+ fs.mkdirSync(path.dirname(home), { recursive: true });
111
+ return withFileLock(lockOf(home), () => {
112
+ const have = shas(home);
113
+ const fresh = !PLUGIN_FILES.some((f) => have[f] !== null);
114
+ const bytes = Object.fromEntries(PLUGIN_FILES.map((f) => [f, fs.readFileSync(path.join(source.dir, f))])) as Record<PluginFile, Buffer>;
115
+ const want = Object.fromEntries(PLUGIN_FILES.map((f) => [f, sha256Hex(bytes[f])])) as Record<PluginFile, string>;
116
+ const written = WRITE_ORDER.filter((f) => have[f] !== want[f]);
117
+ for (const f of written) writeFileAtomic(path.join(home, f), bytes[f]);
118
+ const record = readRecord(home);
119
+ const recordStale = !record || record.version !== BRIDGE_VERSION || PLUGIN_FILES.some((f) => record.files[f] !== want[f]);
120
+ if (written.length || recordStale) {
121
+ const next: InstallRecord = { version: BRIDGE_VERSION, files: want, from: source.kind, installedAt: now().toISOString() };
122
+ writeFileAtomic(path.join(home, RECORD), JSON.stringify(next, null, 2) + "\n");
123
+ }
124
+ return {
125
+ status: fresh ? "installed" : written.length ? "updated" : "current",
126
+ home,
127
+ manifest: path.join(home, "manifest.json"),
128
+ version: BRIDGE_VERSION,
129
+ written,
130
+ manifestChanged: !fresh && have["manifest.json"] !== null && have["manifest.json"] !== want["manifest.json"],
131
+ };
132
+ });
133
+ }
134
+
135
+ /** Remove the files an install wrote (and the folder, once empty). Anything else in it is left alone. */
136
+ export function uninstallPlugin(home: string = pluginHome()): string[] {
137
+ if (!fs.existsSync(home)) return [];
138
+ return withFileLock(lockOf(home), () => {
139
+ const removed: string[] = [];
140
+ for (const f of [...PLUGIN_FILES, RECORD]) {
141
+ const p = path.join(home, f);
142
+ if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); removed.push(f); }
143
+ }
144
+ try { fs.rmdirSync(home); } catch { /* not empty, or gone: left as it is */ }
145
+ return removed;
146
+ });
147
+ }
148
+
149
+ // Reopening runs replaced files only while Figma's hot reload is on; with it off, Figma kept the old code.
150
+ const REOPEN = "reopen the plugin in Figma (Plugins → Development → Design Twin) with Plugins → Development → Hot reload plugin on, to run the new code";
151
+ const importStep = (manifest: string): string => "Plugins → Development → Import plugin from manifest… → " + manifest;
152
+ /** The manifest's folder is hidden in a file picker (a dot folder, or AppData on Windows): how to get there anyway. */
153
+ export function hiddenHint(manifest: string): string | null {
154
+ if (process.platform === "darwin" && manifest.split(path.sep).some((s) => s.startsWith("."))) return "the folder is hidden in Finder: in the file picker press ⌘⇧G and paste the path";
155
+ if (process.platform === "win32") return "the folder is hidden in Explorer: paste the path into the file picker's File name box";
156
+ return null;
157
+ }
158
+ /** What to do in Figma after an install, an update or nothing (empty: nothing to do). */
159
+ export function nextSteps(r: InstallResult): string[] {
160
+ if (r.status === "installed") {
161
+ const hint = hiddenHint(r.manifest);
162
+ return ["once, in Figma DESKTOP: " + importStep(r.manifest), ...(hint ? [hint] : []),
163
+ "imported the plugin from somewhere else before (a clone, the package folder)? Remove that one: Plugins → Manage plugins"];
164
+ }
165
+ if (r.manifestChanged) return [REOPEN + "; its manifest (permissions / network access) changed too — if a new permission still does not work, re-import once: " + importStep(r.manifest)];
166
+ if (r.status === "updated") return [REOPEN + " — no re-import needed"];
167
+ return [];
168
+ }
169
+
170
+ let refreshed = false;
171
+ /** Bring an EXISTING per-user copy up to this dtwin's plugin files — once per process, never from a checkout, never
172
+ * creating one. Failures are logged, never thrown: a refresh must not stop a bridge from starting. */
173
+ export function refreshInstalledPlugin(log: (m: string) => void, source: PluginSource | null = pluginSource(), home: string = pluginHome()): InstallResult | null {
174
+ if (refreshed) return null;
175
+ refreshed = true;
176
+ if (source?.kind !== "package" || !fs.existsSync(path.join(home, RECORD))) return null;
177
+ try {
178
+ const r = installPlugin(source, home);
179
+ if (r.status !== "current") log(`updated the Figma plugin at ${home} to v${r.version ?? "?"} — ` + (nextSteps(r)[0] ?? ""));
180
+ return r;
181
+ } catch (e) {
182
+ log(`could not refresh the Figma plugin at ${home}: ${errMsg(e)} — run \`dtwin plugin update\``);
183
+ return null;
184
+ }
185
+ }
186
+ /** Test seam: let refreshInstalledPlugin run again in this process. */
187
+ export const resetRefreshForTests = (): void => { refreshed = false; };
188
+
189
+ /** One line on the state, for `dtwin plugin status` and doctor. */
190
+ export function describeState(s: PluginState): { ok: boolean; detail: string; next?: string } {
191
+ if (s.source?.kind === "checkout") return { ok: true, detail: `this dtwin runs from a repo checkout — import ${s.importPath} itself (the per-user copy is not refreshed from here)` };
192
+ if (!s.installed) {
193
+ if (!s.source) return { ok: false, detail: "this dtwin carries no Figma plugin files", next: "reinstall it: bun add -g @design-twin/dtwin" };
194
+ return { ok: false, detail: `not installed in ${s.home}`, next: `dtwin plugin install, then (once) in Figma DESKTOP: ${importStep(s.importPath)}` };
195
+ }
196
+ const v = s.record?.version ? `v${s.record.version} ` : "";
197
+ if (s.current === false) return { ok: false, detail: `${v}at ${s.home} differs from this dtwin's (v${BRIDGE_VERSION ?? "?"})`, next: "dtwin plugin update" };
198
+ return { ok: true, detail: `${v}installed at ${s.home} — import ${s.importPath}` };
199
+ }
200
+
201
+ function reveal(manifest: string): void {
202
+ const [cmd, args] = process.platform === "darwin" ? ["open", ["-R", manifest]]
203
+ : process.platform === "win32" ? ["explorer", ["/select," + manifest]]
204
+ : ["xdg-open", [path.dirname(manifest)]];
205
+ try {
206
+ const p = spawn(cmd, args, { stdio: "ignore", detached: true });
207
+ p.on("error", () => { /* no file manager: the path is printed anyway */ });
208
+ p.unref();
209
+ } catch { /* the path is printed anyway */ }
210
+ }
211
+
212
+ export const PLUGIN_HELP =
213
+ "dtwin plugin [install|update|status|path|uninstall] [--json] [--open]\n\n" +
214
+ " The Figma plugin is imported into Figma DESKTOP as a development plugin, from a folder that must not move.\n" +
215
+ " install copy this dtwin's plugin to a per-user folder (" + path.join("<config dir>", "figma-plugin") + ", beside the bridge\n" +
216
+ " token; DESIGNTWIN_CONFIG_DIR moves it) and print the one-time import step. Writes only what differs.\n" +
217
+ " update the same, then says to reopen the plugin (with Plugins → Development → Hot reload plugin on)\n" +
218
+ " status (default) where it is installed, and whether it matches this dtwin\n" +
219
+ " path print the manifest to import (stdout): a checkout's own figma-plugin/manifest.json, else the copy's\n" +
220
+ " uninstall remove the copied files (then remove the plugin in Figma: Plugins → Manage plugins)\n\n" +
221
+ " --json machine output on stdout\n" +
222
+ " --open show the manifest in Finder / Explorer (install, update, path)\n\n" +
223
+ " Once installed, the copy is kept current by itself: every dtwin that starts a bridge refreshes it from its own\n" +
224
+ " package, so after upgrading @design-twin/dtwin you only reopen the plugin (hot reload on). Never from a checkout.\n" +
225
+ " Needs no bridge, no plugin and no token; never touches the network.";
226
+
227
+ const SUBS = ["install", "update", "status", "path", "uninstall"] as const;
228
+ type Sub = (typeof SUBS)[number];
229
+ const isSub = (s: string | undefined): s is Sub => SUBS.some((x) => x === s);
230
+
231
+ export function main(argv: string[]): void {
232
+ if (argv.includes("--help") || argv.includes("-h")) { console.log(PLUGIN_HELP); exitCli(0); }
233
+ const json = argv.includes("--json"), open = argv.includes("--open");
234
+ const rest = argv.filter((a) => a !== "--json" && a !== "--open");
235
+ const word = rest[0] ?? "status";
236
+ if (!isSub(word) || rest.length > 1) {
237
+ console.error(`[dtwin plugin] error: unknown \`dtwin plugin ${rest.join(" ")}\` — use: dtwin plugin [${SUBS.join("|")}] [--json] [--open]`);
238
+ exitCli(1);
239
+ }
240
+ const log = (m: string): void => console.error("[dtwin plugin] " + m);
241
+ const source = pluginSource();
242
+
243
+ if (word === "status" || word === "path") {
244
+ const s = pluginState(source);
245
+ if (word === "path") {
246
+ console.log(json ? JSON.stringify({ importPath: s.importPath, installed: s.installed || s.source?.kind === "checkout" }) : s.importPath);
247
+ if (!s.installed && s.source?.kind !== "checkout") log("not installed yet — run `dtwin plugin install`");
248
+ if (open) reveal(s.importPath);
249
+ return;
250
+ }
251
+ const d = describeState(s);
252
+ if (json) console.log(JSON.stringify({ ...s, version: BRIDGE_VERSION, ok: d.ok, detail: d.detail, ...(d.next ? { next: d.next } : {}) }, null, 2));
253
+ else {
254
+ log(`${d.ok ? "✓" : "!"} ${d.detail}`);
255
+ if (d.next) log(" → " + d.next);
256
+ }
257
+ return;
258
+ }
259
+
260
+ if (word === "uninstall") {
261
+ const removed = uninstallPlugin();
262
+ if (json) console.log(JSON.stringify({ home: pluginHome(), removed }));
263
+ else log(removed.length ? `removed ${removed.join(", ")} from ${pluginHome()} — now remove the plugin in Figma too: Plugins → Manage plugins` : `nothing installed in ${pluginHome()}`);
264
+ return;
265
+ }
266
+
267
+ if (!source) {
268
+ log("error: this dtwin carries no Figma plugin files to install — reinstall it: bun add -g @design-twin/dtwin");
269
+ exitCli(1);
270
+ }
271
+ let r: InstallResult;
272
+ try { r = installPlugin(source); } catch (e) {
273
+ log(`error: could not install the Figma plugin into ${pluginHome()}: ${errMsg(e)}`);
274
+ exitCli(1);
275
+ }
276
+ const steps = nextSteps(r);
277
+ if (json) console.log(JSON.stringify({ ...r, next: steps }, null, 2));
278
+ else {
279
+ const what = r.status === "current" ? "already up to date" : r.status;
280
+ log(`✓ Figma plugin v${r.version ?? "?"} ${what} at ${r.home}`);
281
+ for (const s of steps) log(" → " + s);
282
+ if (source.kind === "checkout") log(" note: this dtwin runs from a repo checkout — you can import figma-plugin/manifest.json there directly");
283
+ }
284
+ if (open) reveal(r.manifest);
285
+ }