@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 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 as before. When Dev answers `WRITE_OUT_OF_SCOPE`,
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. **publish** belongs to sets awaiting publish. **leave
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 awaiting set published, and Dev flags any published component
212
- from an unfinished set. `figma published CS-123 ...` keeps the per-set route.
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.12";
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
- let response;
116
- try {
117
- response = await fetch(url, { method, headers, body: bodyText });
118
- } catch (error) {
119
- throw new CliError("DEV_UNREACHABLE", `Cannot reach Dev at ${new URL(url).origin}: ${error.message}`);
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
- 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(", ")}`] : []),
@@ -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
- return fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/published`, payload);
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
- export const isChangeSetId = (value) => typeof value === "string" && /^CS-\d+$/i.test(value);
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
- 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.13",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {