@nodii/dev 0.6.12 → 0.6.13
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 +11 -4
- package/dist/cli.js +3 -4
- package/dist/core.js +32 -10
- package/dist/figma.js +39 -5
- package/dist/hooks.js +43 -14
- package/dist/oauth.js +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -193,7 +193,11 @@ whole file, as older CLIs do, and that conflicts with every other set.
|
|
|
193
193
|
widened silently.
|
|
194
194
|
|
|
195
195
|
Every write must fall inside the set's scopes (MW1-57.7). The post-hook
|
|
196
|
-
logs each write's node ids
|
|
196
|
+
logs each write's node ids, in colon form. It also logs the variable,
|
|
197
|
+
collection and style ids (`VariableID:…`, `VariableCollectionId:…`, `S:…`)
|
|
198
|
+
that a write returns, including those in a `use_figma` script's JSON
|
|
199
|
+
result, so Dev can tell a foundations write. Dev checks containment at
|
|
200
|
+
`submit`, `resubmit` and `apply`. When Dev answers `WRITE_OUT_OF_SCOPE`,
|
|
197
201
|
the hook exits `2`, so Claude Code shows the agent why: a write that
|
|
198
202
|
returned no node ids (return them from the script, then run `figma log-write`),
|
|
199
203
|
or ids outside the scopes. Dev refuses `figma submit` and `figma resubmit`
|
|
@@ -205,11 +209,14 @@ base version.
|
|
|
205
209
|
Scopes release when a set reaches `awaiting_publish`. One library publish
|
|
206
210
|
then covers every set awaiting publish. Before you publish in Figma,
|
|
207
211
|
`figma publish-plan FILE_KEY` lists the components changed since the last
|
|
208
|
-
publish in two groups
|
|
212
|
+
publish in two groups, and warns about any component that sets from both
|
|
213
|
+
groups changed. **publish** belongs to sets awaiting publish. **leave
|
|
209
214
|
unchecked** belongs to sets still editing or in review: untick those in
|
|
210
215
|
Figma's publish dialog. Then `figma published FILE_KEY --figma-version VERSION`
|
|
211
|
-
marks every
|
|
212
|
-
|
|
216
|
+
marks every set applied before that version as published (pass the
|
|
217
|
+
library publish's version id). Dev holds back a set whose component an
|
|
218
|
+
unfinished set now holds, and flags any published component from an
|
|
219
|
+
unfinished set. `figma published CS-123 ...` keeps the per-set route.
|
|
213
220
|
|
|
214
221
|
`figma withdraw` moves a change set you hold from `in_review` back to
|
|
215
222
|
`editing`, as long as no item has been decided (Dev's
|
package/dist/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
figmaChangeSetText,
|
|
32
32
|
figmaFileKey,
|
|
33
33
|
figmaFilePublished,
|
|
34
|
+
figmaFilePublishedText,
|
|
34
35
|
figmaLock,
|
|
35
36
|
figmaLockCheck,
|
|
36
37
|
figmaLockText,
|
|
@@ -616,9 +617,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
616
617
|
// One publish covers every set awaiting publish on the file (MW1-57.8); a
|
|
617
618
|
// change set id keeps the per-set route.
|
|
618
619
|
if (action === "published" && rest[0] && !isChangeSetId(rest[0]))
|
|
619
|
-
return emit(await figmaFilePublished(rest[0], f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") }), json, (p) =>
|
|
620
|
-
`Published ${p.data.published?.length ?? 0} change set(s): ${(p.data.published ?? []).join(", ") || "none awaiting publish"}` +
|
|
621
|
-
(p.data.flagged?.length ? `\nFLAGGED: published components from unfinished sets: ${JSON.stringify(p.data.flagged)}` : ""));
|
|
620
|
+
return emit(await figmaFilePublished(rest[0], f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") }), json, (p) => figmaFilePublishedText(p.data));
|
|
622
621
|
if (action === "submit") {
|
|
623
622
|
const payload = f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") };
|
|
624
623
|
return emit(await figmaSubmit(rest[0], payload), json);
|
|
@@ -670,7 +669,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
670
669
|
return emit(
|
|
671
670
|
await devRequest(
|
|
672
671
|
"POST",
|
|
673
|
-
`/api/v1/design/files/${encode(rest[0])}/lock/release`,
|
|
672
|
+
`/api/v1/design/files/${encode(figmaFileKey(rest[0]))}/lock/release`,
|
|
674
673
|
{},
|
|
675
674
|
),
|
|
676
675
|
json,
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.6.
|
|
7
|
+
export const VERSION = "0.6.13";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
|
@@ -105,20 +105,42 @@ export function requestUrl(config, path) {
|
|
|
105
105
|
return `${config.edgeUrl.replace(/\/$/, "")}/devops${path}`;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
const staleCode = (raw) => {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(raw)?.code === "token_stale";
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
108
116
|
async function devFetch(method, path, bodyText, contentType, accept) {
|
|
109
117
|
const config = await readConfig();
|
|
110
118
|
const auth = await authProvider();
|
|
111
|
-
const headers = { accept };
|
|
112
|
-
if (bodyText !== undefined) headers["content-type"] = contentType;
|
|
113
|
-
Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
|
|
114
119
|
const url = requestUrl(config, path);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
// The edge refuses a token minted before the principal's claims epoch with
|
|
121
|
+
// 401 token_stale (refresh and retry once). The request never reached Dev,
|
|
122
|
+
// so it is sent again: with a token another process cached since, or with
|
|
123
|
+
// one refresh. A token_stale after that refresh is surfaced.
|
|
124
|
+
let refreshed = false;
|
|
125
|
+
let adopted = false;
|
|
126
|
+
for (;;) {
|
|
127
|
+
const headers = { accept };
|
|
128
|
+
if (bodyText !== undefined) headers["content-type"] = contentType;
|
|
129
|
+
Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await fetch(url, { method, headers, body: bodyText });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw new CliError("DEV_UNREACHABLE", `Cannot reach Dev at ${new URL(url).origin}: ${error.message}`);
|
|
135
|
+
}
|
|
136
|
+
const raw = await response.text();
|
|
137
|
+
if (response.status !== 401 || typeof auth.refreshStale !== "function" || !staleCode(raw)) return { response, raw };
|
|
138
|
+
if (refreshed) return { response, raw };
|
|
139
|
+
// A cached token that is stale too is not adopted twice: the next one is refreshed.
|
|
140
|
+
const used = String(headers.authorization ?? "").replace(/^Bearer /, "");
|
|
141
|
+
if ((await auth.refreshStale(used, { force: adopted })) === "refreshed") refreshed = true;
|
|
142
|
+
else adopted = true;
|
|
120
143
|
}
|
|
121
|
-
return { response, raw: await response.text() };
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
|
package/dist/figma.js
CHANGED
|
@@ -150,7 +150,8 @@ const conflictText = (message, details) => {
|
|
|
150
150
|
].join("\n");
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
/** A variable, variable collection or style id: a write to one is a foundations write. */
|
|
154
|
+
export const FOUNDATIONS_ID = /^(?:VariableID:\d+:\d+|VariableCollectionId:\d+:\d+|S:[0-9a-f]+(?:,\d+:\d+)?)$/;
|
|
154
155
|
|
|
155
156
|
/** Dev's WRITE_OUT_OF_SCOPE, with what to do about it (MW1-57.7). */
|
|
156
157
|
export function outOfScopeText(message, details, id = details?.changeSetId ?? "CHANGE_SET") {
|
|
@@ -161,7 +162,7 @@ export function outOfScopeText(message, details, id = details?.changeSetId ?? "C
|
|
|
161
162
|
`The write returned no node ids, so Dev can't check it against ${id}'s scopes.`,
|
|
162
163
|
`Return the ids you wrote from the script, then run: nodii-dev figma log-write ${id} --tool TOOL --nodes ID,ID`,
|
|
163
164
|
].join("\n");
|
|
164
|
-
const foundations = ids.filter((x) => FOUNDATIONS_ID.test(x));
|
|
165
|
+
const foundations = ids.filter((x) => FOUNDATIONS_ID.test(x) || /^S:/.test(x));
|
|
165
166
|
return [
|
|
166
167
|
message,
|
|
167
168
|
...(ids.length ? [`Out of scope: ${ids.join(", ")}`] : []),
|
|
@@ -250,18 +251,45 @@ export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {})
|
|
|
250
251
|
/** One publish for the file: every awaiting_publish set on it (MW1-57.8). */
|
|
251
252
|
export async function figmaFilePublished(value, payload, { request = devRequest } = {}) {
|
|
252
253
|
const key = figmaFileKey(value);
|
|
253
|
-
|
|
254
|
+
try {
|
|
255
|
+
return await fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/published`, payload);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error?.code !== "FIGMA_VERSION_NOT_FOUND") throw error;
|
|
258
|
+
throw new CliError(error.code, `${error.message}\nPass the id of the library publish's version, from the file's version history in Figma.`, error.exitCode ?? 1, error.details);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const idOf = (x) => (typeof x === "string" ? x : (x?.changeSetId ?? x?.id ?? JSON.stringify(x)));
|
|
263
|
+
const list = (ids) => (ids?.length ? ids.map(idOf).join(", ") : "none");
|
|
264
|
+
|
|
265
|
+
/** What a file-level publish did: published, held back for the next publish, and anything Dev flags. */
|
|
266
|
+
export function figmaFilePublishedText(result) {
|
|
267
|
+
return [
|
|
268
|
+
`Published ${result.published?.length ?? 0} change set(s): ${list(result.published)}`,
|
|
269
|
+
...(result.heldBack?.length
|
|
270
|
+
? [`Held back for the next publish (an unfinished set now holds their component, and this publish didn't include it): ${list(result.heldBack)}`]
|
|
271
|
+
: []),
|
|
272
|
+
...(result.versionTime === "unverified" ? ["Dev couldn't verify when that Figma version was made."] : []),
|
|
273
|
+
...(result.flagged?.length
|
|
274
|
+
? [`FLAGGED: this publish includes components from unfinished sets: ${result.flagged.map((f) => (typeof f === "string" ? f : `${f.nodeId ?? "?"}${f.name ? ` ${f.name}` : ""} (${f.changeSetId ?? "?"})`)).join(", ")}`]
|
|
275
|
+
: []),
|
|
276
|
+
].join("\n");
|
|
254
277
|
}
|
|
255
278
|
|
|
256
279
|
// A change set id (CS-123) takes the per-set route; a file key or link takes the file's.
|
|
257
|
-
|
|
280
|
+
// Anything else starting CS- is a mistyped id, never a file.
|
|
281
|
+
export function isChangeSetId(value) {
|
|
282
|
+
if (typeof value !== "string" || !/^CS-/i.test(value)) return false;
|
|
283
|
+
if (!/^CS-\d+$/i.test(value)) throw new CliError("USAGE", `${value} is not a change set id (CS-123) or a Figma file key`, 2);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
258
286
|
|
|
259
287
|
export async function figmaPublishPlan(value, { request = devRequest } = {}) {
|
|
260
288
|
const key = figmaFileKey(value);
|
|
261
289
|
return fileCall(key, request)("GET", `/api/v1/design/files/${encode(key)}/publish-plan`);
|
|
262
290
|
}
|
|
263
291
|
|
|
264
|
-
const planLine = (x) => ` ${x.nodeId ?? scopeText(x)} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
|
|
292
|
+
const planLine = (x) => ` ${x.nodeId ?? (x.kind ? scopeText(x) : "(no node)")} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
|
|
265
293
|
|
|
266
294
|
/** The publish plan as two groups: what to publish, and what to leave unchecked in Figma's publish dialog. */
|
|
267
295
|
export function figmaPublishPlanText(plan) {
|
|
@@ -273,6 +301,12 @@ export function figmaPublishPlanText(plan) {
|
|
|
273
301
|
...(publish.length ? publish.map(planLine) : [" nothing awaiting publish"]),
|
|
274
302
|
`leave unchecked (${leave.length}): sets still editing or in review`,
|
|
275
303
|
...(leave.length ? leave.map(planLine) : [" none"]),
|
|
304
|
+
...(plan.collisions?.length
|
|
305
|
+
? [
|
|
306
|
+
`WARNING: ${plan.collisions.length} component(s) changed by both a set to publish and an unfinished set; Figma can't publish half of one:`,
|
|
307
|
+
...plan.collisions.map((c) => ` ${c.nodeId} ${c.name ?? ""} publish ${list([c.publish].flat())}; leave unchecked ${list([c.leaveUnchecked].flat())}`),
|
|
308
|
+
]
|
|
309
|
+
: []),
|
|
276
310
|
...(publish.length
|
|
277
311
|
? [`After publishing in Figma: nodii-dev figma published ${plan.fileKey} --figma-version VERSION`]
|
|
278
312
|
: []),
|
package/dist/hooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError, devRequest } from "./core.js";
|
|
2
|
-
import { figmaLockCheck, outOfScopeText } from "./figma.js";
|
|
2
|
+
import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
|
|
@@ -59,17 +59,47 @@ export function extractFileKey(input) {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// Figma ids, in the colon form Dev stores: 12-34 (a link's form) is 12:34,
|
|
63
|
+
// I12-34;56-78 is I12:34;56:78. Variables, collections and styles carry
|
|
64
|
+
// their own ids (VariableID:1:2, VariableCollectionId:1:2, S:<hash>,1:2);
|
|
65
|
+
// a write to one is a foundations write (MW1-57.7).
|
|
66
|
+
const FOUNDATIONS_IN_TEXT = new RegExp(`(?<![\\w:])${FOUNDATIONS_ID.source.replace(/^\^|\$$/g, "")}`, "g");
|
|
67
|
+
// id, ids, nodeId(s), node_ids, createdNodeIds, mutatedNodeIds, variableIds, styleId, ...
|
|
68
|
+
const ID_KEY = /^(?:ids?|\w*node_?ids?|(?:variable|style|collection)_?ids?)$/i;
|
|
69
|
+
const FIGMA_NODE_ID = /^I?\d+[:-]\d+(?:;\d+[:-]\d+)*$/;
|
|
70
|
+
// A tool's input may name a node by any id its server uses; a result is held to Figma's shapes.
|
|
71
|
+
const INPUT_ID = /^(?:\d+[:;-]\d+|[A-Za-z0-9_-]{8,})$/;
|
|
72
|
+
const colonForm = (id) =>
|
|
73
|
+
/^I?\d+-\d+(?:;\d+-\d+)*$/.test(id) ? id.replace(/(\d+)-(\d+)/g, "$1:$2") : id;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The node ids in hook data: values under id-like keys, and any variable,
|
|
77
|
+
* collection or style id. With `text`, a JSON string (a tool's text result)
|
|
78
|
+
* is read as JSON, and other text is searched for variable and style ids;
|
|
79
|
+
* a tool's input (its script) is never searched as text.
|
|
80
|
+
*/
|
|
81
|
+
export function extractNodeIds(value, out = new Set(), { text = false } = {}) {
|
|
82
|
+
if (typeof value === "string") {
|
|
83
|
+
if (!text) return [...out];
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
if (/^[[{]/.test(trimmed)) {
|
|
86
|
+
try {
|
|
87
|
+
return extractNodeIds(JSON.parse(trimmed), out, { text });
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
for (const id of value.match(FOUNDATIONS_IN_TEXT) ?? []) out.add(id);
|
|
91
|
+
return [...out];
|
|
92
|
+
}
|
|
63
93
|
if (!value || typeof value !== "object") return [...out];
|
|
64
94
|
for (const [key, item] of Object.entries(value)) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (typeof item === "object") extractNodeIds(item, out);
|
|
95
|
+
const keyed = ID_KEY.test(key);
|
|
96
|
+
for (const v of Array.isArray(item) ? item : [item]) {
|
|
97
|
+
if (typeof v !== "string") continue;
|
|
98
|
+
if (FOUNDATIONS_ID.test(v)) out.add(v);
|
|
99
|
+
else if (keyed && (FIGMA_NODE_ID.test(v) || (!text && INPUT_ID.test(v)))) out.add(colonForm(v));
|
|
100
|
+
else if (text) extractNodeIds(v, out, { text });
|
|
101
|
+
}
|
|
102
|
+
if (typeof item === "object") extractNodeIds(item, out, { text });
|
|
73
103
|
}
|
|
74
104
|
return [...out];
|
|
75
105
|
}
|
|
@@ -119,10 +149,9 @@ export async function postHook(payload, request = devRequest, config = async ()
|
|
|
119
149
|
const checked = await figmaLockCheck(fileKey, agentId, request);
|
|
120
150
|
if (!checked.data?.allowed || !checked.data.changeSetId)
|
|
121
151
|
return { logged: false, reason: checked.data?.reason ?? "lock denied" };
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
});
|
|
152
|
+
const found = new Set();
|
|
153
|
+
extractNodeIds(payload.tool_input, found);
|
|
154
|
+
const nodeIds = extractNodeIds(payload.tool_response, found, { text: true });
|
|
126
155
|
if (!nodeIds.length) nodeIds.push("unknown");
|
|
127
156
|
try {
|
|
128
157
|
await request(
|
package/dist/oauth.js
CHANGED
|
@@ -525,6 +525,27 @@ export class OAuthAuthProvider {
|
|
|
525
525
|
})().finally(() => { this.#refreshPromise = undefined; });
|
|
526
526
|
return this.#refreshPromise;
|
|
527
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* The edge refused `stale` as minted before the principal's claims epoch
|
|
530
|
+
* (401 token_stale). Get a token past it: one another process cached since
|
|
531
|
+
* (resolves "cache"), or else a refresh under the lock, re-checking the
|
|
532
|
+
* cache once the lock is held so concurrent processes rotate only once
|
|
533
|
+
* (resolves "refreshed"). `force` skips the cache.
|
|
534
|
+
*/
|
|
535
|
+
async refreshStale(stale, { force = false } = {}) {
|
|
536
|
+
if (this.#accessToken === stale) {
|
|
537
|
+
this.#accessToken = undefined;
|
|
538
|
+
this.#expiresAt = 0;
|
|
539
|
+
}
|
|
540
|
+
const config = await readConfig();
|
|
541
|
+
const fresh = (cached) => !force && cached?.token !== stale && this.#usable(cached);
|
|
542
|
+
if (fresh(await this.loadAccess(config.clientId))) return "cache";
|
|
543
|
+
return this.lock(async ({ held } = {}) => {
|
|
544
|
+
if (fresh(await this.loadAccess(config.clientId))) return "cache";
|
|
545
|
+
await this.#refresh(config, held ?? (async () => true));
|
|
546
|
+
return "refreshed";
|
|
547
|
+
}, { clientId: config.clientId }).finally(() => this.#release());
|
|
548
|
+
}
|
|
528
549
|
async #attempt(config, refresh, deadline, held) {
|
|
529
550
|
const floor = Math.min(MIN_ATTEMPT_MS, this.timeoutMs);
|
|
530
551
|
let result;
|