@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 +78 -10
- package/figma-plugin/code.js +87 -5
- package/figma-plugin/ui.html +39 -3
- package/package.json +1 -1
- package/src/bridge-holder.ts +4 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands.ts +14 -1
- package/src/doctor.ts +13 -2
- package/src/figma-mcp.ts +35 -6
- package/src/figma-pull.ts +7 -1
- package/src/init.ts +53 -10
- package/src/plugin-install.ts +285 -0
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
|
|
18
|
-
|
|
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
|
-
> **
|
|
39
|
-
>
|
|
40
|
-
>
|
|
41
|
-
>
|
|
42
|
-
>
|
|
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
|
|
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**
|
package/figma-plugin/code.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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;
|
package/figma-plugin/ui.html
CHANGED
|
@@ -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
|
|
311
|
-
|
|
312
|
-
: "
|
|
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.
|
|
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",
|
package/src/bridge-holder.ts
CHANGED
|
@@ -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) } : {});
|
package/src/cli-commands.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
380
|
-
//
|
|
381
|
-
|
|
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
|
+
}
|