@design-twin/dtwin 0.1.1 → 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 +46 -11
- package/figma-plugin/code.js +1 -1
- package/package.json +1 -1
- package/src/bridge-holder.ts +4 -0
- package/src/cli-commands.ts +1 -0
- package/src/doctor.ts +13 -2
- package/src/figma-pull.ts +2 -1
- package/src/init.ts +23 -5
- package/src/plugin-install.ts +285 -0
package/README.md
CHANGED
|
@@ -14,17 +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
20
|
|
|
21
21
|
Or with Homebrew (macOS, Linux), which installs Bun for you and keeps both up to date with `brew upgrade`:
|
|
22
22
|
```
|
|
23
23
|
brew install its-magdy/tap/dtwin
|
|
24
24
|
```
|
|
25
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
|
|
27
|
-
survives an upgrade — not the versioned `Cellar/dtwin/<version>/` folder the next `brew upgrade` deletes.
|
|
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.
|
|
28
29
|
|
|
29
30
|
Or from a clone (to work on the code):
|
|
30
31
|
```
|
|
@@ -43,11 +44,13 @@ The plugin manifest allows the bridge in **both** dev and published builds
|
|
|
43
44
|
(`allowedDomains: ws://localhost:{8787,8788,8789}`). Those three ports are the only ones the plugin can dial — `FIGMA_BRIDGE_PORT` is
|
|
44
45
|
validated against them and exits rather than binding a port nothing could ever reach.
|
|
45
46
|
|
|
46
|
-
> **
|
|
47
|
-
>
|
|
48
|
-
>
|
|
49
|
-
>
|
|
50
|
-
>
|
|
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.
|
|
51
54
|
|
|
52
55
|
**Languages:** the whole bridge is **TypeScript** (ESM) under `src/` — `figma-pull.ts` (read CLI),
|
|
53
56
|
`server-core.ts` (WS core), `figma-mcp.ts` (MCP server), and the modules they share. From a checkout,
|
|
@@ -190,7 +193,7 @@ README documents the flags, and either spelling is fine everywhere:
|
|
|
190
193
|
| `dtwin serve` · `stop` · `status` | `--serve` · `--stop` · `--daemon-status` |
|
|
191
194
|
| `dtwin token` · `token show` · `token rotate` · `token forget` | `--token-status` · `--show-token` · `--rotate-token` · `--forget-token` |
|
|
192
195
|
| `dtwin help` | `--help` |
|
|
193
|
-
| `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) |
|
|
194
197
|
|
|
195
198
|
Every verb has its own page: `dtwin <verb> --help` (e.g. `dtwin whoami --help`, `dtwin serve --help`) prints
|
|
196
199
|
that verb's flags and exits without starting a bridge. `outDir` is **positional** (`dtwin pull my-dir`):
|
|
@@ -204,6 +207,38 @@ With no outDir the export lands in `design/export` — the only place doctor, cr
|
|
|
204
207
|
build-screen read. Never pull into bare `design`: that writes a second, parallel export tree beside
|
|
205
208
|
`design/export/` (dtwin warns when it sees one).
|
|
206
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
|
+
|
|
207
242
|
## Seed the component map from Code Connect: `dtwin seed`
|
|
208
243
|
|
|
209
244
|
If the project you are building already has Code Connect files, they are the Figma-component →
|
package/figma-plugin/code.js
CHANGED
|
@@ -4069,7 +4069,7 @@
|
|
|
4069
4069
|
// freshly reloaded one, since neither startedAt nor code.js's mtime can. Reused verbatim by
|
|
4070
4070
|
// the `hello` announcement below (main.ts's get-identity -> ui.html -> bridge), so `whoami`,
|
|
4071
4071
|
// `dtwin list clients` and `dtwin doctor` can never disagree about which build is running.
|
|
4072
|
-
pluginVersion: "0.1.
|
|
4072
|
+
pluginVersion: "0.1.2",
|
|
4073
4073
|
fileKey: fileKey === void 0 ? null : fileKey,
|
|
4074
4074
|
fileKeyAvailable: typeof fileKey === "string" && fileKey.length > 0,
|
|
4075
4075
|
// So figma_write's dry run can say a real write would be refused before anyone sends one.
|
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/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-pull.ts
CHANGED
|
@@ -1146,6 +1146,7 @@ const ROUTES = {
|
|
|
1146
1146
|
init: async (args) => { (await import("./init.ts")).main(args); },
|
|
1147
1147
|
doctor: async (args) => { await (await import("./doctor.ts")).main(args); },
|
|
1148
1148
|
seed: async (args) => { (await import("./seed-components.ts")).main(args); },
|
|
1149
|
+
plugin: async (args) => { (await import("./plugin-install.ts")).main(args); },
|
|
1149
1150
|
} satisfies Record<RoutedVerb, (args: string[]) => Promise<void>>;
|
|
1150
1151
|
const routed = (word: string | undefined): word is RoutedVerb => word !== undefined && Object.hasOwn(ROUTES, word);
|
|
1151
1152
|
|
|
@@ -1177,7 +1178,7 @@ async function runMcp(args: string[]): Promise<void> {
|
|
|
1177
1178
|
// first, then --version/--help, then the arguments are parsed, THEN the settings are read (settings.ts — port, token
|
|
1178
1179
|
// with --token-file, limits) and the command runs.
|
|
1179
1180
|
async function cli(argv: string[]): Promise<void> {
|
|
1180
|
-
// `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
|
|
1181
1182
|
// the settings are read): `dtwin mcp` is taken ONLY when the first argument is exactly "mcp", so `dtwin …` and
|
|
1182
1183
|
// importing this module from the test suites are untouched, and `bun bridge/src/figma-mcp.ts` still works.
|
|
1183
1184
|
const word = argv[0];
|
package/src/init.ts
CHANGED
|
@@ -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 {
|
|
@@ -400,10 +402,25 @@ function main(argv: string[]): void {
|
|
|
400
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}`);
|
|
401
403
|
else apply(cwd, actions, log);
|
|
402
404
|
|
|
403
|
-
// The Figma plugin's manifest: a repo checkout's
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
const manifest =
|
|
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
|
+
})();
|
|
407
424
|
// Step 3 is already done for anyone who reached init THROUGH the installed plugin's help skill —
|
|
408
425
|
// which is the documented path. Claude Code records installed plugins under ~/.claude; when the
|
|
409
426
|
// marker is there, saying "install the plugin" is noise at best and confusing at worst.
|
|
@@ -425,7 +442,8 @@ function main(argv: string[]): void {
|
|
|
425
442
|
})();
|
|
426
443
|
|
|
427
444
|
const steps = [
|
|
428
|
-
"In Figma DESKTOP: Plugins → Development → Import plugin from manifest… → " + (manifest ?? "figma-plugin/manifest.json from a clone of the Design Twin repo (this install does not include it)")
|
|
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) ?? ""})` : ""),
|
|
429
447
|
"Run the plugin (Plugins → Development → Design Twin), paste the bridge token into its \"Bridge token\" field, Save: dtwin --show-token | pbcopy",
|
|
430
448
|
];
|
|
431
449
|
if (!pluginInstalled) {
|
|
@@ -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
|
+
}
|