@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/dist/figma.js CHANGED
@@ -150,7 +150,8 @@ const conflictText = (message, details) => {
150
150
  ].join("\n");
151
151
  };
152
152
 
153
- const FOUNDATIONS_ID = /^(?:S:|VariableID:|VariableCollectionId:)/;
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
- return fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/published`, payload);
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
- export const isChangeSetId = (value) => typeof value === "string" && /^CS-\d+$/i.test(value);
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
- export function extractNodeIds(value, out = new Set()) {
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
- if (/^(node_?ids?|id)$/i.test(key))
66
- for (const v of Array.isArray(item) ? item : [item])
67
- if (
68
- typeof v === "string" &&
69
- /^(?:\d+[:;-]\d+|[A-Za-z0-9_-]{8,})$/.test(v)
70
- )
71
- out.add(v);
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 nodeIds = extractNodeIds({
123
- input: payload.tool_input,
124
- response: payload.tool_response,
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {