@design-twin/dtwin 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/timeouts.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// timeouts.ts — the per-command time budgets, split out of server-core.ts (same reason as
|
|
2
|
+
// staleness.ts): figma-pull.ts's exported, test-driven parseArgs (and read-plan.ts) pick their budgets from this table,
|
|
3
|
+
// and it need not load the whole bridge core just to read two constants. No load-time effects. Every caller imports
|
|
4
|
+
// them from here.
|
|
5
|
+
import { ALLOWED_PORTS } from "./ports.ts";
|
|
6
|
+
|
|
7
|
+
// Per-COMMAND time budgets, in ONE table. The budget is a property of the work, not of the caller, so
|
|
8
|
+
// both front-ends (figma-pull CLI, figma-mcp) read it from here instead of restating the tiers — a
|
|
9
|
+
// fourth caller would otherwise inherit `command` again, which is the 120s default tuned for
|
|
10
|
+
// ping/getSelection and far too small for anything that walks a page.
|
|
11
|
+
// selection — one hand-picked selection (small by construction)
|
|
12
|
+
// list — cheap RELATIVE to an export, not fast: depth 2 loads EVERY page, which Figma's own
|
|
13
|
+
// docs call "slow for large documents". The tool you're told to call FIRST must not be
|
|
14
|
+
// the first to fail on exactly the large files where looking before you pull matters.
|
|
15
|
+
// export — one page / one node: found the hard way on a ~1000-node file.
|
|
16
|
+
// exportAll — whole file / --all-pages: measured >15 min on a real 25-page file.
|
|
17
|
+
export const TIMEOUTS = { command: 120000, selection: 120000, list: 300000, export: 300000, exportAll: 900000 };
|
|
18
|
+
|
|
19
|
+
// The scope -> tier RULE is read-plan.ts's (with the rest of what a read sends): hoisting only the numbers
|
|
20
|
+
// still left both front-ends restating which tier an export picks, and they had already drifted once (the
|
|
21
|
+
// MCP copy omitted the selection tier).
|
|
22
|
+
|
|
23
|
+
// How often the bridge pings each connected plugin socket, and so how long a silently dead one (a
|
|
24
|
+
// laptop lid closed mid-session, a pulled cable, a renderer that vanished without a FIN) can sit in the
|
|
25
|
+
// client registry looking live: a client that has not answered the PREVIOUS ping when the next tick
|
|
26
|
+
// fires is terminated, so detection takes between one and two intervals. 30 s is the interval of the
|
|
27
|
+
// ws README's own "How to detect and close broken connections?" example. Browsers answer pings in the
|
|
28
|
+
// network stack without involving page script, so a plugin busy with a long export still answers.
|
|
29
|
+
// createBridge's `heartbeatMs` option overrides it (the test suite uses a short one).
|
|
30
|
+
export const HEARTBEAT_MS = 30000;
|
|
31
|
+
|
|
32
|
+
// How long a NAMED --client target (file name / fileKey, not a c<N> connId) is waited for after the
|
|
33
|
+
// first plugin connects. Every open plugin window redials every PLUGIN_REDIAL_MS, one port at a time, so
|
|
34
|
+
// a file that is open lands within one walk of the three ports (3 s); 15 s is five such walks — room for
|
|
35
|
+
// a window Figma throttles in the background — and a name that never matches fails with the connected
|
|
36
|
+
// list instead of holding the terminal for the whole connect window.
|
|
37
|
+
export const NAMED_CLIENT_WAIT_MS = 15000;
|
|
38
|
+
|
|
39
|
+
// The longest delay one setTimeout holds: "When delay is larger than 2147483647 or less than 1, the delay
|
|
40
|
+
// will be set to 1" (nodejs.org/api/timers.html#settimeoutcallback-delay-args), so a longer wait would
|
|
41
|
+
// fire at once — or, re-armed, spin. Every wait or guard built from a caller's number stays within it.
|
|
42
|
+
export const MAX_TIMER_MS = 2147483647;
|
|
43
|
+
|
|
44
|
+
// How often an open plugin window redials a bridge it has lost or not found yet: figma-plugin ui.template.html
|
|
45
|
+
// REDIAL_MS (scheduleReconnect's `ms || REDIAL_MS`; test/ui.test.ts pins the two together), one port of
|
|
46
|
+
// the three per attempt (connect() walks BRIDGE_PORTS on every failed dial), so a window that keeps
|
|
47
|
+
// missing comes back to the same port every 3 s.
|
|
48
|
+
export const PLUGIN_REDIAL_MS = 1000;
|
|
49
|
+
|
|
50
|
+
// How long a bridge whose plugins have only just started connecting holds a request that does not name
|
|
51
|
+
// one file by connId or fileKey (no target, or a file name — names repeat and match as substrings) before
|
|
52
|
+
// routing it. Two open files redial on their own clocks, so right after a bridge starts, whichever lands
|
|
53
|
+
// first is all there is for a moment — and an unaddressed (or half-addressed) command would silently go to
|
|
54
|
+
// that file instead of being refused as ambiguous. A second window can be anywhere in its walk of the
|
|
55
|
+
// three ports, so it reaches this bridge's port up to one full walk (3 × PLUGIN_REDIAL_MS) after the
|
|
56
|
+
// first; that plus a margin for its `hello` covers every window that is redialling normally, for 3.5 s
|
|
57
|
+
// on an unaddressed one-shot command. The walk assumes a refused dial fails at once, as it does on macOS
|
|
58
|
+
// and Linux; on Windows a refused localhost connect can take about 2 s, so a walk there is longer and the
|
|
59
|
+
// settle covers less of it. A window Figma throttles in the background can still land later.
|
|
60
|
+
// Counted from the moment the bridge went from no plugin to one, and restarted when a plugin leaves while
|
|
61
|
+
// others stay (it is probably redialling), so a bridge whose plugins have been connected for longer (a
|
|
62
|
+
// daemon, a long MCP session) pays nothing until one of them leaves; a connId or fileKey is routed at
|
|
63
|
+
// once. A request's own connect window cannot cut the settle short.
|
|
64
|
+
export const SETTLE_MS = ALLOWED_PORTS.length * PLUGIN_REDIAL_MS + 500;
|
|
65
|
+
|
|
66
|
+
// The client-side guard on a request sent through a daemon: every phase the daemon's bridge may spend on it — the
|
|
67
|
+
// connect window, then at most NAMED_CLIENT_WAIT_MS for a named target (server-core awaitClient); a wait in the plugin's
|
|
68
|
+
// queue behind an earlier run in that file (figma-plugin state.ts scheduleRun), bounded by the command timeout armed
|
|
69
|
+
// at send; then the run itself, given the whole command timeout again when it starts (server-core re-arms it on the
|
|
70
|
+
// request's `start` frame) — and a margin (it also absorbs the settle, SETTLE_MS), so the daemon's own, clearer error
|
|
71
|
+
// normally lands before the guard does. Two command timeouts, because a request that takes no progress never relays
|
|
72
|
+
// that `start` to the guard (one that does re-arms the guard on every frame instead, as above). Not always first: the
|
|
73
|
+
// MAX_TIMER_MS cap below can cut the sum short.
|
|
74
|
+
// Capped at MAX_TIMER_MS: the guard is one setTimeout, which runs a longer delay after 1 ms.
|
|
75
|
+
export const daemonGuardMs = (timeoutMs: number, connectWaitMs: number = 0): number =>
|
|
76
|
+
Math.min(2 * timeoutMs + connectWaitMs + Math.min(connectWaitMs, NAMED_CLIENT_WAIT_MS) + 30000, MAX_TIMER_MS);
|
package/src/timings.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// timings.ts — the opt-in stage record of one dtwin command, for profiling rather than for a user: set
|
|
2
|
+
// DTWIN_TIMINGS=1 (one JSON line on stderr) or DTWIN_TIMINGS=<file> (appended to that file as JSON Lines).
|
|
3
|
+
//
|
|
4
|
+
// The closing `done in …` line already splits a command into connect / export / write, but "export" is one number for
|
|
5
|
+
// everything between the request leaving and the reply landing: a run queued behind another export in that file, the
|
|
6
|
+
// plugin's page walk, its catalogue build, its render pass and the transfer all fall in it. The plugin's progress
|
|
7
|
+
// ticks (server-core.ts ProgressTick) already arrive per phase, so their arrival times split that span without a
|
|
8
|
+
// protocol change: `phases[]` says when each phase was first and last heard, measured on this side (transport latency
|
|
9
|
+
// included). `queueMs` is the time to the first tick of any phase other than `queued`, when the run was queued.
|
|
10
|
+
//
|
|
11
|
+
// What is recorded is counts, sizes and times only — phase names, page index/of, node and asset counters, payload
|
|
12
|
+
// bytes, peak RSS. Never a page or layer name, a file name or key, a token, or document content.
|
|
13
|
+
//
|
|
14
|
+
// Measured with performance.now() (monotonic); peak RSS from process.resourceUsage().maxRSS (kilobytes on every
|
|
15
|
+
// platform Bun supports, per Node's documentation of the field — converted to bytes here).
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { errMsg } from "./errmsg.ts";
|
|
18
|
+
|
|
19
|
+
export const TIMINGS_ENV = "DTWIN_TIMINGS";
|
|
20
|
+
export const TIMINGS_SCHEMA = "designtwin/timings@1";
|
|
21
|
+
|
|
22
|
+
/** Where a record goes: stderr ("1"/"true"), a file path (anything else non-empty), or nowhere (unset, "", "0"). */
|
|
23
|
+
export function timingsTarget(env: Record<string, string | undefined> = process.env): "stderr" | { file: string } | null {
|
|
24
|
+
const v = (env[TIMINGS_ENV] ?? "").trim();
|
|
25
|
+
if (!v || v === "0" || v.toLowerCase() === "false") return null;
|
|
26
|
+
return v === "1" || v.toLowerCase() === "true" ? "stderr" : { file: v };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PhaseSpan { phase: string; firstMs: number; lastMs: number; ticks: number }
|
|
30
|
+
/** The asset payload of a reply: how many assets and their decoded bytes (base64 counted as the bytes it decodes to). */
|
|
31
|
+
export interface PayloadStats { assets: number; assetBytes: number; nodes: number | null }
|
|
32
|
+
export interface StageRecord {
|
|
33
|
+
schema: typeof TIMINGS_SCHEMA;
|
|
34
|
+
command: string;
|
|
35
|
+
viaDaemon: boolean;
|
|
36
|
+
totalMs: number; connectMs: number; exportMs: number; writeMs: number;
|
|
37
|
+
/** request sent → first tick of a phase other than `queued`; null when the run was never queued */
|
|
38
|
+
queueMs: number | null;
|
|
39
|
+
phases: PhaseSpan[];
|
|
40
|
+
requests: number;
|
|
41
|
+
payload: PayloadStats | null;
|
|
42
|
+
peakRssBytes: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const round1 = (ms: number): number => Math.round(ms * 10) / 10;
|
|
46
|
+
|
|
47
|
+
/** Decoded byte length of a base64 string without decoding it. */
|
|
48
|
+
export function base64Bytes(b64: string): number {
|
|
49
|
+
const n = b64.length;
|
|
50
|
+
if (!n) return 0;
|
|
51
|
+
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
|
52
|
+
return Math.floor((n * 3) / 4) - pad;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** PayloadStats of one reply (read defensively: it is the plugin's JSON). null when it carries no `assets` array. */
|
|
56
|
+
export function payloadStats(result: unknown): PayloadStats | null {
|
|
57
|
+
if (!result || typeof result !== "object") return null;
|
|
58
|
+
const r = result as { assets?: unknown; manifest?: { nodes?: unknown }; screen?: { manifest?: { nodes?: unknown } } };
|
|
59
|
+
if (!Array.isArray(r.assets)) return null;
|
|
60
|
+
let assetBytes = 0;
|
|
61
|
+
for (const a of r.assets as Array<{ base64?: unknown; text?: unknown } | null>) {
|
|
62
|
+
if (!a) continue;
|
|
63
|
+
if (typeof a.text === "string") assetBytes += Buffer.byteLength(a.text, "utf8");
|
|
64
|
+
else if (typeof a.base64 === "string") assetBytes += base64Bytes(a.base64);
|
|
65
|
+
}
|
|
66
|
+
const nodes = r.screen?.manifest?.nodes ?? r.manifest?.nodes;
|
|
67
|
+
return { assets: r.assets.length, assetBytes, nodes: typeof nodes === "number" ? nodes : null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The clock of one command: started at creation; fed every request, reply and progress tick. */
|
|
71
|
+
export interface StageClock {
|
|
72
|
+
sent(): void;
|
|
73
|
+
replied(result: unknown): void;
|
|
74
|
+
tick(phase: string | null): void;
|
|
75
|
+
record(o: { command: string; viaDaemon: boolean; connectMs: number; exportMs: number; writeMs: number }): StageRecord;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function stageClock(now: () => number = () => performance.now(), rss: () => number | null = peakRss): StageClock {
|
|
79
|
+
const t0 = now();
|
|
80
|
+
const phases = new Map<string, PhaseSpan>();
|
|
81
|
+
let firstSent: number | null = null, queued = false, queueMs: number | null = null, requests = 0;
|
|
82
|
+
let payload: PayloadStats | null = null;
|
|
83
|
+
return {
|
|
84
|
+
sent() { requests++; if (firstSent === null) firstSent = now() - t0; },
|
|
85
|
+
replied(result) {
|
|
86
|
+
const p = payloadStats(result);
|
|
87
|
+
if (!p) return;
|
|
88
|
+
payload = payload ? { assets: payload.assets + p.assets, assetBytes: payload.assetBytes + p.assetBytes, nodes: p.nodes ?? payload.nodes } : p;
|
|
89
|
+
},
|
|
90
|
+
tick(phase) {
|
|
91
|
+
const at = now() - t0;
|
|
92
|
+
const name = phase || "unnamed";
|
|
93
|
+
if (name === "queued") queued = true;
|
|
94
|
+
else if (queued && queueMs === null && firstSent !== null) queueMs = round1(at - firstSent);
|
|
95
|
+
const s = phases.get(name);
|
|
96
|
+
if (s) { s.lastMs = round1(at); s.ticks++; } else phases.set(name, { phase: name, firstMs: round1(at), lastMs: round1(at), ticks: 1 });
|
|
97
|
+
},
|
|
98
|
+
record(o) {
|
|
99
|
+
return {
|
|
100
|
+
schema: TIMINGS_SCHEMA, command: o.command, viaDaemon: o.viaDaemon,
|
|
101
|
+
totalMs: round1(now() - t0), connectMs: round1(o.connectMs), exportMs: round1(o.exportMs), writeMs: round1(o.writeMs),
|
|
102
|
+
queueMs, phases: [...phases.values()], requests, payload, peakRssBytes: rss(),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function peakRss(): number | null {
|
|
109
|
+
try { return process.resourceUsage().maxRSS * 1024; } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Write one record to its target. A file that cannot be appended to is said on stderr, never thrown: a profiling
|
|
113
|
+
* aid must not fail the command it measures. */
|
|
114
|
+
export function emitTimings(rec: StageRecord, target: "stderr" | { file: string }, err: (s: string) => void = (s) => console.error(s)): void {
|
|
115
|
+
const line = JSON.stringify(rec);
|
|
116
|
+
if (target === "stderr") { err("[dtwin] timings " + line); return; }
|
|
117
|
+
try { fs.appendFileSync(target.file, line + "\n"); } catch (e) { err(`[dtwin] ${TIMINGS_ENV}: could not append to ${target.file} (${errMsg(e)})`); }
|
|
118
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Where the bridge token LIVES between runs — the one place that knows the path, the precedence,
|
|
2
|
+
// and the file's permissions.
|
|
3
|
+
//
|
|
4
|
+
// The token is not env-var-or-nothing: `server-core.ts` reads FIGMA_BRIDGE_TOKEN, and minting a fresh
|
|
5
|
+
// random one PER RUN and printing it would leave the plugin's saved token wrong on the very next `dtwin`,
|
|
6
|
+
// so the user would re-paste forever. The fix is not a better banner, it's persistence: generate once,
|
|
7
|
+
// store it, reuse it.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately dependency-free. `env-paths` is the usual answer for per-OS config dirs, but the logic is
|
|
10
|
+
// ~15 lines, so it lives here rather than costing a dependency.
|
|
11
|
+
//
|
|
12
|
+
// Not a `.env` file either: a project-local `.env` would be the wrong CONVENTION — `.env` is for the
|
|
13
|
+
// secrets of the app you are building, not for a tool's own credential, and putting it in the repo makes "did we gitignore it" a permanent live footgun.
|
|
14
|
+
// Real CLIs (gh, wrangler, vercel) keep their own auth in a user config dir. So do we.
|
|
15
|
+
//
|
|
16
|
+
// Not the OS keychain either: keytar is archived, and its live replacements (@napi-rs/keyring,
|
|
17
|
+
// keyring-node) are native binaries with per-platform install friction. That is disproportionate for
|
|
18
|
+
// a loopback-only token that grants no authority beyond "drive a Figma plugin the same user already
|
|
19
|
+
// has open". The honest limit of the 0600 file, stated so it isn't a surprise: it stops OTHER users
|
|
20
|
+
// on the machine, not another process running as YOU.
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { writeFileAtomic } from "./atomic-write.ts";
|
|
27
|
+
import { sha256Hex } from "./hash.ts";
|
|
28
|
+
import { errMsg } from "./errmsg.ts";
|
|
29
|
+
|
|
30
|
+
/** Where the token in play came from. "ephemeral" = minted for this run only, never stored. */
|
|
31
|
+
export type TokenSource = "token-file" | "env" | "file" | "ephemeral";
|
|
32
|
+
|
|
33
|
+
/** What resolve() returns. `path` is the file the token lives in (null for env/ephemeral). */
|
|
34
|
+
export interface ResolvedToken {
|
|
35
|
+
token: string;
|
|
36
|
+
source: TokenSource;
|
|
37
|
+
path: string | null;
|
|
38
|
+
/** true only on the run that minted AND stored a new token */
|
|
39
|
+
created: boolean;
|
|
40
|
+
/** why a minted token could not be stored (the result is then "ephemeral") */
|
|
41
|
+
persistError?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResolveOptions {
|
|
45
|
+
tokenFile?: string | null;
|
|
46
|
+
persist?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What --token-status prints: everything about the token except the token. */
|
|
50
|
+
export interface TokenStatus {
|
|
51
|
+
path: string;
|
|
52
|
+
stored: boolean;
|
|
53
|
+
envSet: boolean;
|
|
54
|
+
activeSource: TokenSource;
|
|
55
|
+
fingerprint: string | null;
|
|
56
|
+
loosePerms: boolean;
|
|
57
|
+
shadowed: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const APP = "design-twin";
|
|
61
|
+
const FILE = "bridge-token";
|
|
62
|
+
|
|
63
|
+
// 24 bytes = 192 bits from the OS CSPRNG, hex-encoded. Matches what server-core minted before.
|
|
64
|
+
export const generate = (): string => crypto.randomBytes(24).toString("hex");
|
|
65
|
+
|
|
66
|
+
// Per-OS config directory.
|
|
67
|
+
//
|
|
68
|
+
// macOS gets ~/.config, NOT ~/Library/Application Support: `gh` does the same, and the Apple location
|
|
69
|
+
// is really for plist-shaped data managed by `defaults`, not a hand-editable secret a user may well
|
|
70
|
+
// want to `cat`. Honouring XDG_CONFIG_HOME when it is set also gives the Linux-conventions-on-macOS
|
|
71
|
+
// crowd what they expect.
|
|
72
|
+
//
|
|
73
|
+
// DESIGNTWIN_CONFIG_DIR overrides everything — the tests need a config dir that is not the real
|
|
74
|
+
// user's, and a container/CI run may want one explicitly.
|
|
75
|
+
export function configDir(): string {
|
|
76
|
+
const override = process.env.DESIGNTWIN_CONFIG_DIR;
|
|
77
|
+
if (override && path.isAbsolute(override)) return override;
|
|
78
|
+
|
|
79
|
+
if (process.platform === "win32") {
|
|
80
|
+
const appData = process.env.APPDATA;
|
|
81
|
+
if (appData && path.isAbsolute(appData)) return path.join(appData, APP);
|
|
82
|
+
return path.join(os.homedir(), "AppData", "Roaming", APP);
|
|
83
|
+
}
|
|
84
|
+
// The XDG spec is explicit that a RELATIVE value here is invalid and must be ignored rather than
|
|
85
|
+
// resolved against cwd — otherwise the token file's location would depend on where you ran dtwin.
|
|
86
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
87
|
+
if (xdg && path.isAbsolute(xdg)) return path.join(xdg, APP);
|
|
88
|
+
return path.join(os.homedir(), ".config", APP);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const tokenPath = (): string => path.join(configDir(), FILE);
|
|
92
|
+
|
|
93
|
+
// A stable, non-secret way to say "the same token?" in logs, `--token-status`, and a 401 hint.
|
|
94
|
+
// Truncated SHA-256, never the token itself — the whole point of persisting is to stop printing it.
|
|
95
|
+
export const fingerprint = (tok: unknown): string | null => (tok ? sha256Hex(String(tok)).slice(0, 8) : null);
|
|
96
|
+
|
|
97
|
+
// Read one token from a path. Trims: a token written with `echo tok > file` carries a trailing
|
|
98
|
+
// newline, which would otherwise fail the handshake with a "bad token" that looks nothing like the
|
|
99
|
+
// whitespace bug it is. Returns null for missing/empty/unreadable rather than throwing — every
|
|
100
|
+
// caller's next move is "fall through to the next source".
|
|
101
|
+
export function readFrom(file: string): string | null {
|
|
102
|
+
try {
|
|
103
|
+
const raw = fs.readFileSync(file, "utf8").trim();
|
|
104
|
+
return raw || null;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Is the file readable by anyone but the owner? POSIX only: on Windows the mode bits are not
|
|
111
|
+
// meaningful (%APPDATA% is already per-user by ACL), so reporting on them would be a warning the
|
|
112
|
+
// user cannot act on.
|
|
113
|
+
export function loosePerms(file: string): boolean {
|
|
114
|
+
if (process.platform === "win32") return false;
|
|
115
|
+
try {
|
|
116
|
+
return (fs.statSync(file).mode & 0o077) !== 0;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Write atomically, owner-only.
|
|
123
|
+
//
|
|
124
|
+
// tmp + rename (atomic-write.ts) because a token half-written by an interrupted run is worse than no token
|
|
125
|
+
// at all: it would fail every handshake with a 401 until someone thought to look at the file. The 0600 mode
|
|
126
|
+
// is set on the tmp file before the rename, so the token is never visible with wider permissions.
|
|
127
|
+
export function write(token: string, file: string = tokenPath()): string {
|
|
128
|
+
const dir = path.dirname(file);
|
|
129
|
+
// 0700: the XDG spec asks for exactly this when creating a config dir that does not exist.
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
131
|
+
writeFileAtomic(file, token + "\n", { mode: 0o600 });
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function remove(file: string = tokenPath()): boolean {
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(file);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return false; // already gone — `--forget-token` twice is not an error
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// THE precedence chain, in one place: explicit flag > environment > stored file > mint a new one.
|
|
145
|
+
//
|
|
146
|
+
// Order follows the usual CLI layering (flag beats env beats persisted config beats default). Env
|
|
147
|
+
// stays ABOVE the file so CI and one-off overrides keep working unchanged, and so anyone who already
|
|
148
|
+
// exported FIGMA_BRIDGE_TOKEN is unaffected by this file existing.
|
|
149
|
+
//
|
|
150
|
+
// Note there is deliberately no `--token <value>` flag anywhere in the CLI, only `--token-file`:
|
|
151
|
+
// process arguments are world-readable via `ps` / /proc/<pid>/cmdline, so a value flag would leak the
|
|
152
|
+
// secret to every other user on the machine.
|
|
153
|
+
//
|
|
154
|
+
// `persist: false` lets a caller ask "what WOULD be used" without creating a file as a side effect —
|
|
155
|
+
// which is what --token-status needs.
|
|
156
|
+
export function resolve({ tokenFile = null, persist = true }: ResolveOptions = {}): ResolvedToken {
|
|
157
|
+
if (tokenFile) {
|
|
158
|
+
const tok = readFrom(tokenFile);
|
|
159
|
+
if (!tok) {
|
|
160
|
+
const err: Error & { code?: string } = new Error(`--token-file ${tokenFile}: not readable, or empty.`);
|
|
161
|
+
err.code = "TOKEN_FILE_UNREADABLE";
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
return { token: tok, source: "token-file", path: tokenFile, created: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fromEnv = (process.env.FIGMA_BRIDGE_TOKEN || "").trim();
|
|
168
|
+
if (fromEnv) return { token: fromEnv, source: "env", path: null, created: false };
|
|
169
|
+
|
|
170
|
+
const file = tokenPath();
|
|
171
|
+
const stored = readFrom(file);
|
|
172
|
+
if (stored) return { token: stored, source: "file", path: file, created: false };
|
|
173
|
+
|
|
174
|
+
const token = generate();
|
|
175
|
+
if (!persist) return { token, source: "ephemeral", path: null, created: false };
|
|
176
|
+
try {
|
|
177
|
+
write(token, file);
|
|
178
|
+
return { token, source: "file", path: file, created: true };
|
|
179
|
+
} catch (e) {
|
|
180
|
+
// Read-only filesystem, no HOME, a locked-down container. Falling back to a per-run token keeps
|
|
181
|
+
// the bridge USABLE instead of failing to start over a convenience feature —
|
|
182
|
+
// the caller reports why so it isn't a silent downgrade.
|
|
183
|
+
return { token, source: "ephemeral", path: null, created: false, persistError: e };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What committing a token on its first use did (commit). */
|
|
188
|
+
export interface CommittedToken {
|
|
189
|
+
/** the token as every later use should see it: a minted one now the saved file's, or kept ephemeral with why */
|
|
190
|
+
resolved: ResolvedToken;
|
|
191
|
+
/** this commit minted it (saved or not) — the one time the token itself is printed */
|
|
192
|
+
minted: boolean;
|
|
193
|
+
/** the mint could not be saved: it changes every run */
|
|
194
|
+
saveFailed: boolean;
|
|
195
|
+
/** what to tell the user, one line each */
|
|
196
|
+
notice: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// THE first use of a resolved token: save it when nothing has (resolve() with persist:false leaves a minted token
|
|
200
|
+
// "ephemeral"), and say which token is in play.
|
|
201
|
+
//
|
|
202
|
+
// The token itself is printed ONCE — on the commit that mints it — and never again. It is stable from here on, the
|
|
203
|
+
// plugin has it saved in clientStorage, and reprinting a live secret into terminal scrollback on every single run is
|
|
204
|
+
// exactly the habit this store exists to end. `--show-token` reveals it on demand; `--token-status` answers "which one
|
|
205
|
+
// is in play" without disclosing it. Committing again what a commit returned saves and prints nothing new.
|
|
206
|
+
// It expects an UNSAVED resolution (resolve() with persist:false, as settings.ts does) or its own output: a token resolve()
|
|
207
|
+
// minted with persist:true and failed to save carries persistError already, and is taken as reported — its paste line
|
|
208
|
+
// is resolve()'s caller's to print.
|
|
209
|
+
export function commit(t: ResolvedToken, file: string = tokenPath()): CommittedToken {
|
|
210
|
+
const fp = fingerprint(t.token);
|
|
211
|
+
if (t.source === "ephemeral" && t.persistError === undefined) {
|
|
212
|
+
const paste = "auth token (paste into the plugin's \"Bridge token\" field): " + t.token;
|
|
213
|
+
try {
|
|
214
|
+
write(t.token, file);
|
|
215
|
+
return {
|
|
216
|
+
resolved: { ...t, source: "file", path: file, created: true }, minted: true, saveFailed: false,
|
|
217
|
+
notice: [paste, `saved to ${file} — you won't be asked to paste it again.`, "see it later with `dtwin --show-token`; replace it with `dtwin --rotate-token`."],
|
|
218
|
+
};
|
|
219
|
+
} catch (e) {
|
|
220
|
+
// Couldn't persist (read-only FS, no HOME, locked-down container). Still works — but say so, because the user
|
|
221
|
+
// WILL have to paste again next run and deserves to know why.
|
|
222
|
+
return {
|
|
223
|
+
resolved: { ...t, persistError: e }, minted: true, saveFailed: true,
|
|
224
|
+
notice: [paste, `could not save it (${errMsg(e)}) — it changes every run. Set FIGMA_BRIDGE_TOKEN to a stable value instead.`],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const done = (...notice: string[]): CommittedToken => ({ resolved: t, minted: false, saveFailed: false, notice });
|
|
229
|
+
if (t.source === "token-file") return done(`auth: token read from ${t.path} (${fp}).`);
|
|
230
|
+
if (t.source === "env") return done(`auth: using FIGMA_BRIDGE_TOKEN from the environment (${fp}).`);
|
|
231
|
+
if (t.source === "ephemeral") return done(`auth: using this run's unsaved token (${fp}).`);
|
|
232
|
+
const notice = [`auth: using the saved token from ${t.path} (${fp}).`];
|
|
233
|
+
if (t.path !== null && loosePerms(t.path)) notice.push(`warning: ${t.path} is readable by other users — chmod 600 it.`);
|
|
234
|
+
return done(...notice);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// What --token-status prints: everything about the token except the token.
|
|
238
|
+
export function status(): TokenStatus {
|
|
239
|
+
const file = tokenPath();
|
|
240
|
+
const stored = readFrom(file);
|
|
241
|
+
const envTok = (process.env.FIGMA_BRIDGE_TOKEN || "").trim();
|
|
242
|
+
const active = resolve({ persist: false });
|
|
243
|
+
return {
|
|
244
|
+
path: file,
|
|
245
|
+
stored: !!stored,
|
|
246
|
+
envSet: !!envTok,
|
|
247
|
+
// Which source actually wins right now — the point of the whole command is that "there is a file"
|
|
248
|
+
// and "the file is what the bridge uses" are different questions when the env var is also set.
|
|
249
|
+
activeSource: active.source,
|
|
250
|
+
// No fingerprint for an ephemeral token. `status()` resolves with persist:false, so the
|
|
251
|
+
// "ephemeral" branch mints a THROWAWAY that nothing will ever use and that differs on every
|
|
252
|
+
// call — printing its fingerprint invites the user to compare it against the plugin's, which is
|
|
253
|
+
// exactly the wrong thing to do. A fingerprint is only meaningful for a token that persists.
|
|
254
|
+
fingerprint: active.source === "ephemeral" ? null : fingerprint(active.token),
|
|
255
|
+
loosePerms: stored ? loosePerms(file) : false,
|
|
256
|
+
// A stored token that is being SHADOWED by a DIFFERENT env var value is the confusing case worth
|
|
257
|
+
// naming — a project that always exports FIGMA_BRIDGE_TOKEN set to the SAME value as the saved
|
|
258
|
+
// file has nothing to fix, and warning on every run just trains the user to ignore the note.
|
|
259
|
+
shadowed: !!stored && !!envTok && stored !== envTok,
|
|
260
|
+
};
|
|
261
|
+
}
|