@nodii/dev 0.6.12 → 0.6.14
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 +20 -5
- package/dist/api.js +1 -1
- package/dist/cli.js +9 -6
- package/dist/core.js +32 -10
- package/dist/devops-openapi.json +1 -1
- package/dist/figma.js +74 -5
- package/dist/hooks.js +43 -14
- package/dist/oauth.js +21 -0
- package/package.json +1 -1
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(", ")}`] : []),
|
|
@@ -241,6 +242,41 @@ export async function figmaScopeAdd(id, scopes, { request = devRequest } = {}) {
|
|
|
241
242
|
return changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Narrows a held set to exactly these scopes (Dev issue 104,
|
|
247
|
+
* setDesignChangeSetScopes); the rest is released for other sets. Each must
|
|
248
|
+
* sit inside what the set holds: the whole file holds everything, a page its
|
|
249
|
+
* components and creates.
|
|
250
|
+
*/
|
|
251
|
+
export async function figmaScopeSet(id, scopes, { request = devRequest } = {}) {
|
|
252
|
+
if (!id || !scopes.length) throw new CliError("USAGE", `figma scope set CHANGE_SET ${SCOPE_USAGE} [--scope ...]`, 2);
|
|
253
|
+
try {
|
|
254
|
+
return await request("PUT", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error?.code === "SCOPE_NOT_NARROWER")
|
|
257
|
+
throw new CliError(error.code, `${error.message}\nscope set only narrows; to claim more, run nodii-dev figma scope add ${id} --scope ...`, error.exitCode ?? 1, error.details);
|
|
258
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
259
|
+
throw new CliError(
|
|
260
|
+
error.code,
|
|
261
|
+
[
|
|
262
|
+
error.message,
|
|
263
|
+
...(error.details?.nodeIds?.length ? [`Would fall outside: ${error.details.nodeIds.join(", ")}`] : []),
|
|
264
|
+
`Keep a scope that holds them (styles and variables need --scope foundations), or revert those nodes to how they were at ${id}'s base version.`,
|
|
265
|
+
].join("\n"),
|
|
266
|
+
error.exitCode ?? 1,
|
|
267
|
+
error.details,
|
|
268
|
+
);
|
|
269
|
+
throw scopeRefusal(error, id) ?? error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** What a narrowing left the set holding, and what it released. */
|
|
274
|
+
export const figmaScopeSetText = (id, data) =>
|
|
275
|
+
[
|
|
276
|
+
`${id} holds ${data?.scopes?.length ?? 0} scope(s): ${(data?.scopes ?? []).map(scopeText).join(", ") || "none"}`,
|
|
277
|
+
...(data?.released?.length ? [`Released for other sets: ${data.released.map(scopeText).join(", ")}`] : []),
|
|
278
|
+
].join("\n");
|
|
279
|
+
|
|
244
280
|
export const figmaSubmit = (id, payload, { request = devRequest } = {}) =>
|
|
245
281
|
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
|
|
246
282
|
|
|
@@ -250,18 +286,45 @@ export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {})
|
|
|
250
286
|
/** One publish for the file: every awaiting_publish set on it (MW1-57.8). */
|
|
251
287
|
export async function figmaFilePublished(value, payload, { request = devRequest } = {}) {
|
|
252
288
|
const key = figmaFileKey(value);
|
|
253
|
-
|
|
289
|
+
try {
|
|
290
|
+
return await fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/published`, payload);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error?.code !== "FIGMA_VERSION_NOT_FOUND") throw error;
|
|
293
|
+
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);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const idOf = (x) => (typeof x === "string" ? x : (x?.changeSetId ?? x?.id ?? JSON.stringify(x)));
|
|
298
|
+
const list = (ids) => (ids?.length ? ids.map(idOf).join(", ") : "none");
|
|
299
|
+
|
|
300
|
+
/** What a file-level publish did: published, held back for the next publish, and anything Dev flags. */
|
|
301
|
+
export function figmaFilePublishedText(result) {
|
|
302
|
+
return [
|
|
303
|
+
`Published ${result.published?.length ?? 0} change set(s): ${list(result.published)}`,
|
|
304
|
+
...(result.heldBack?.length
|
|
305
|
+
? [`Held back for the next publish (an unfinished set now holds their component, and this publish didn't include it): ${list(result.heldBack)}`]
|
|
306
|
+
: []),
|
|
307
|
+
...(result.versionTime === "unverified" ? ["Dev couldn't verify when that Figma version was made."] : []),
|
|
308
|
+
...(result.flagged?.length
|
|
309
|
+
? [`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(", ")}`]
|
|
310
|
+
: []),
|
|
311
|
+
].join("\n");
|
|
254
312
|
}
|
|
255
313
|
|
|
256
314
|
// A change set id (CS-123) takes the per-set route; a file key or link takes the file's.
|
|
257
|
-
|
|
315
|
+
// Anything else starting CS- is a mistyped id, never a file.
|
|
316
|
+
export function isChangeSetId(value) {
|
|
317
|
+
if (typeof value !== "string" || !/^CS-/i.test(value)) return false;
|
|
318
|
+
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);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
258
321
|
|
|
259
322
|
export async function figmaPublishPlan(value, { request = devRequest } = {}) {
|
|
260
323
|
const key = figmaFileKey(value);
|
|
261
324
|
return fileCall(key, request)("GET", `/api/v1/design/files/${encode(key)}/publish-plan`);
|
|
262
325
|
}
|
|
263
326
|
|
|
264
|
-
const planLine = (x) => ` ${x.nodeId ?? scopeText(x)} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
|
|
327
|
+
const planLine = (x) => ` ${x.nodeId ?? (x.kind ? scopeText(x) : "(no node)")} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
|
|
265
328
|
|
|
266
329
|
/** The publish plan as two groups: what to publish, and what to leave unchecked in Figma's publish dialog. */
|
|
267
330
|
export function figmaPublishPlanText(plan) {
|
|
@@ -273,6 +336,12 @@ export function figmaPublishPlanText(plan) {
|
|
|
273
336
|
...(publish.length ? publish.map(planLine) : [" nothing awaiting publish"]),
|
|
274
337
|
`leave unchecked (${leave.length}): sets still editing or in review`,
|
|
275
338
|
...(leave.length ? leave.map(planLine) : [" none"]),
|
|
339
|
+
...(plan.collisions?.length
|
|
340
|
+
? [
|
|
341
|
+
`WARNING: ${plan.collisions.length} component(s) changed by both a set to publish and an unfinished set; Figma can't publish half of one:`,
|
|
342
|
+
...plan.collisions.map((c) => ` ${c.nodeId} ${c.name ?? ""} publish ${list([c.publish].flat())}; leave unchecked ${list([c.leaveUnchecked].flat())}`),
|
|
343
|
+
]
|
|
344
|
+
: []),
|
|
276
345
|
...(publish.length
|
|
277
346
|
? [`After publishing in Figma: nodii-dev figma published ${plan.fileKey} --figma-version VERSION`]
|
|
278
347
|
: []),
|
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;
|