@nodii/dev 0.6.11 → 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/dist/doctor.js CHANGED
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { devRequest, encode, isOAuthConfig, query, setAuthProvider } from "./core.js";
5
5
  import { OAuthAuthProvider, decodeClaims, loadRefreshToken } from "./oauth.js";
6
6
  import { inferGitContext, repoName } from "./lanes.js";
7
- import { figmaFileKey, notRegisteredHint } from "./figma.js";
7
+ import { figmaFileKey, figmaLockCheck, holdsSet, notRegisteredHint, scopeText } from "./figma.js";
8
8
 
9
9
  /**
10
10
  * What a lane's commands need, in Dev's route policy (staffRuleFor in Dev's
@@ -237,6 +237,19 @@ export async function doctorCommand(deps = {}) {
237
237
  const file = (await request("GET", `/api/v1/design/files/${encode(fileKey)}`)).data;
238
238
  checks.push(ok("figma_file", `${fileKey} is registered: ${file.name} (${file.kind}${file.module ? `, ${file.module}` : ""})`,
239
239
  { fileKey, registered: true }));
240
+ // The lock scopes this agent holds in the file (DEC-0032).
241
+ if (config.agentId) {
242
+ try {
243
+ const held = (await figmaLockCheck(fileKey, config.agentId, request)).data;
244
+ const mine = holdsSet(held);
245
+ const scopes = mine ? (held.scopes ?? []) : [];
246
+ checks.push(ok("figma_scopes", mine
247
+ ? `${held.changeSetId} (${held.state}) holds ${scopes.length ? scopes.map(scopeText).join(", ") : "the whole file"}`
248
+ : `holds no scopes in ${fileKey}`, { fileKey, changeSetId: mine ? held.changeSetId : null, scopes }));
249
+ } catch (error) {
250
+ checks.push(cannot("figma_scopes", `checkDesignFileLock failed (${devReason(error)})`, { fileKey }));
251
+ }
252
+ }
240
253
  } catch (error) {
241
254
  if (error?.code === "DESIGN_FILE_NOT_FOUND")
242
255
  checks.push(missing("figma_file", `${fileKey} is NOT registered: run figma file add; figma lock fails with DESIGN_FILE_NOT_FOUND`,
package/dist/figma.js CHANGED
@@ -68,7 +68,7 @@ export async function figmaResubmit(id, file, { request = devRequest, read = (f)
68
68
  return await request("POST", `/api/v1/design/change-sets/${encode(id)}/resubmit`, body);
69
69
  } catch (error) {
70
70
  const say = RESUBMIT_REFUSALS[error?.code];
71
- if (!say) throw error;
71
+ if (!say) throw scopeRefusal(error, id) ?? error;
72
72
  throw new CliError(error.code, say(id, error.details, error.message), error.exitCode ?? 1, error.details);
73
73
  }
74
74
  }
@@ -104,3 +104,211 @@ export function figmaChangeSetText(cs) {
104
104
  if (cs.previousRounds?.length) lines.push(`${cs.previousRounds.length} earlier-round item(s) replaced by a re-submit; --json lists them.`);
105
105
  return lines.join("\n");
106
106
  }
107
+
108
+ // Scoped design locks (DEC-0032, MW1-57.6 to 57.9). A scope is written
109
+ // kind:id on the command line: component:NODE, page:NODE, foundations, or
110
+ // create:PAGE:NAME. A node id from a figma.com link (12-34) is Figma's 12:34.
111
+ const nodeId = (text) => (/^\d+-\d+$/.test(text) ? text.replace("-", ":") : text);
112
+ const SCOPE_USAGE = "--scope component:NODE_ID | page:NODE_ID | foundations | create:PAGE_ID:NAME";
113
+
114
+ export function parseScope(value) {
115
+ const text = typeof value === "string" ? value.trim() : "";
116
+ const bad = () => new CliError("USAGE", `bad scope ${JSON.stringify(text)}: use ${SCOPE_USAGE}`, 2);
117
+ if (text === "foundations") return { kind: "foundations" };
118
+ const create = text.match(/^create:(\d+[:-]\d+):(.*)$/);
119
+ if (create && create[2].trim() && create[2].trim().length <= 200)
120
+ return { kind: "create", pageId: nodeId(create[1]), name: create[2].trim() };
121
+ // Dev takes a scope at a top-level node: a plain id, never an instance's (I1:2;3:4).
122
+ const node = text.match(/^(component|page):(\d+[:-]\d+)$/);
123
+ if (!node) throw bad();
124
+ return { kind: node[1], nodeId: nodeId(node[2]) };
125
+ }
126
+
127
+ /** Every --scope flag as a scope; none at all is a whole-file lock (MW1-57.9). */
128
+ export function scopesFrom(flags) {
129
+ const given = flags.scope === undefined ? [] : [flags.scope].flat();
130
+ return given.map((value) => {
131
+ if (value === true) throw new CliError("USAGE", `--scope needs a value: ${SCOPE_USAGE}`, 2);
132
+ return parseScope(value);
133
+ });
134
+ }
135
+
136
+ export const scopeText = (s) =>
137
+ !s ? "?"
138
+ : s.kind === "foundations" ? "foundations"
139
+ : s.kind === "file" ? "whole file"
140
+ : s.kind === "create" ? `create:${s.pageId}:${s.name}`
141
+ : `${s.kind}:${s.nodeId}`;
142
+
143
+ const conflictText = (message, details) => {
144
+ const held = details?.heldBy;
145
+ if (!held) return message;
146
+ return [
147
+ message,
148
+ `${scopeText(details.requested)} overlaps ${scopeText(held.scope)}, held by ${held.changeSetId}${held.title ? ` "${held.title}"` : ""}.`,
149
+ `Claim a scope that doesn't overlap, or wait until ${held.changeSetId} reaches awaiting_publish (scopes release there).`,
150
+ ].join("\n");
151
+ };
152
+
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+)?)$/;
155
+
156
+ /** Dev's WRITE_OUT_OF_SCOPE, with what to do about it (MW1-57.7). */
157
+ export function outOfScopeText(message, details, id = details?.changeSetId ?? "CHANGE_SET") {
158
+ const ids = details?.nodeIds ?? [];
159
+ if (details?.reason === "unknown-ids" || (ids.length && ids.every((x) => x === "unknown")))
160
+ return [
161
+ message,
162
+ `The write returned no node ids, so Dev can't check it against ${id}'s scopes.`,
163
+ `Return the ids you wrote from the script, then run: nodii-dev figma log-write ${id} --tool TOOL --nodes ID,ID`,
164
+ ].join("\n");
165
+ const foundations = ids.filter((x) => FOUNDATIONS_ID.test(x) || /^S:/.test(x));
166
+ return [
167
+ message,
168
+ ...(ids.length ? [`Out of scope: ${ids.join(", ")}`] : []),
169
+ ...(foundations.length
170
+ ? [`Styles and variables (${foundations.join(", ")}) need the foundations scope: nodii-dev figma scope add ${id} --scope foundations`]
171
+ : []),
172
+ `Widen with nodii-dev figma scope add ${id} --scope component:NODE_ID|page:NODE_ID if the scope is free, or revert the node exactly to how it was at the set's base version, then submit again.`,
173
+ ].join("\n");
174
+ }
175
+
176
+ // Keep Dev's code and message; add what to do next.
177
+ function scopeRefusal(error, id) {
178
+ if (error?.code === "SCOPE_CONFLICT")
179
+ return new CliError(error.code, conflictText(error.message, error.details), error.exitCode ?? 1, error.details);
180
+ if (error?.code === "WRITE_OUT_OF_SCOPE")
181
+ return new CliError(error.code, outOfScopeText(error.message, error.details, id), error.exitCode ?? 1, error.details);
182
+ return null;
183
+ }
184
+
185
+ /** A change-set route whose scope refusals say what to do next. */
186
+ const changeSetCall = (id, request) => async (method, path, body) => {
187
+ try {
188
+ return await request(method, path, body);
189
+ } catch (error) {
190
+ throw scopeRefusal(error, id) ?? error;
191
+ }
192
+ };
193
+
194
+ /** A file route that names the `figma file add` command for an unregistered file. */
195
+ const fileCall = (key, request) => async (method, path, body) => {
196
+ try {
197
+ return await request(method, path, body);
198
+ } catch (error) {
199
+ if (error?.code === "DESIGN_FILE_NOT_FOUND")
200
+ throw new CliError(error.code, notRegisteredHint(key), error.exitCode ?? 1, error.details);
201
+ throw scopeRefusal(error) ?? error;
202
+ }
203
+ };
204
+
205
+ /** May this agent write to the file? Its own set, state and scopes (checkDesignFileLock). */
206
+ export const figmaLockCheck = (fileKey, agentId, request = devRequest) =>
207
+ request("GET", `/api/v1/design/files/${encode(fileKey)}/lock/check?agentId=${encode(agentId)}`);
208
+
209
+ /** Whether a lock check answers for this agent's own set (it holds one), not another agent's. */
210
+ export const holdsSet = (check) => Boolean(check?.changeSetId) && (check.allowed || check.reason === "change-set-not-editable");
211
+
212
+ const isWholeFile = (scopes) => !scopes?.length || scopes.some((s) => s.kind === "file");
213
+
214
+ export async function figmaLock(value, title, { scopes = [], request = devRequest } = {}) {
215
+ const key = figmaFileKey(value);
216
+ const result = await fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/lock`, {
217
+ changeSetTitle: title,
218
+ ...(scopes.length ? { scopes } : {}),
219
+ });
220
+ // A Dev that doesn't know scopes ignores them and locks the whole file,
221
+ // which blocks every other lane: say so loudly instead of exiting 0.
222
+ if (scopes.length && isWholeFile(result?.data?.scopes))
223
+ throw new CliError(
224
+ "SCOPES_NOT_APPLIED",
225
+ `Dev locked the whole of ${key} as ${result?.data?.id ?? "a change set"}, not the scopes asked for (this Dev predates scoped locks). It blocks every other change set on the file: run nodii-dev figma abandon ${result?.data?.id ?? "CHANGE_SET"}.`,
226
+ 1,
227
+ result?.data,
228
+ );
229
+ return result;
230
+ }
231
+
232
+ export const figmaLockText = (cs) =>
233
+ [
234
+ `${cs.id} ${cs.state ?? "editing"} ${cs.title}`,
235
+ isWholeFile(cs.scopes)
236
+ ? `Locked the whole of ${cs.fileKey}: this conflicts with every other change set on the file. Claim scopes instead with ${SCOPE_USAGE}.`
237
+ : `Holds ${cs.scopes.length} scope(s) in ${cs.fileKey}: ${cs.scopes.map(scopeText).join(", ")}`,
238
+ ].join("\n");
239
+
240
+ export async function figmaScopeAdd(id, scopes, { request = devRequest } = {}) {
241
+ if (!id || !scopes.length) throw new CliError("USAGE", `figma scope add CHANGE_SET ${SCOPE_USAGE} [--scope ...]`, 2);
242
+ return changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
243
+ }
244
+
245
+ export const figmaSubmit = (id, payload, { request = devRequest } = {}) =>
246
+ changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
247
+
248
+ export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {}) =>
249
+ changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/writes`, { tool, nodeIds });
250
+
251
+ /** One publish for the file: every awaiting_publish set on it (MW1-57.8). */
252
+ export async function figmaFilePublished(value, payload, { request = devRequest } = {}) {
253
+ const key = figmaFileKey(value);
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");
277
+ }
278
+
279
+ // A change set id (CS-123) takes the per-set route; a file key or link takes the file's.
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
+ }
286
+
287
+ export async function figmaPublishPlan(value, { request = devRequest } = {}) {
288
+ const key = figmaFileKey(value);
289
+ return fileCall(key, request)("GET", `/api/v1/design/files/${encode(key)}/publish-plan`);
290
+ }
291
+
292
+ const planLine = (x) => ` ${x.nodeId ?? (x.kind ? scopeText(x) : "(no node)")} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
293
+
294
+ /** The publish plan as two groups: what to publish, and what to leave unchecked in Figma's publish dialog. */
295
+ export function figmaPublishPlanText(plan) {
296
+ const publish = plan.publish ?? [];
297
+ const leave = plan.leaveUnchecked ?? [];
298
+ return [
299
+ `Publish plan for ${plan.fileKey}${plan.sinceVersion ? ` (changes since version ${plan.sinceVersion})` : ""}`,
300
+ `publish (${publish.length}): sets awaiting publish`,
301
+ ...(publish.length ? publish.map(planLine) : [" nothing awaiting publish"]),
302
+ `leave unchecked (${leave.length}): sets still editing or in review`,
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
+ : []),
310
+ ...(publish.length
311
+ ? [`After publishing in Figma: nodii-dev figma published ${plan.fileKey} --figma-version VERSION`]
312
+ : []),
313
+ ].join("\n");
314
+ }
package/dist/hooks.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CliError, devRequest } from "./core.js";
2
+ import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
2
3
  import { execFile } from "node:child_process";
3
4
  import { promisify } from "node:util";
4
5
 
@@ -58,17 +59,47 @@ export function extractFileKey(input) {
58
59
  return null;
59
60
  }
60
61
 
61
- 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
+ }
62
93
  if (!value || typeof value !== "object") return [...out];
63
94
  for (const [key, item] of Object.entries(value)) {
64
- if (/^(node_?ids?|id)$/i.test(key))
65
- for (const v of Array.isArray(item) ? item : [item])
66
- if (
67
- typeof v === "string" &&
68
- /^(?:\d+[:;-]\d+|[A-Za-z0-9_-]{8,})$/.test(v)
69
- )
70
- out.add(v);
71
- 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 });
72
103
  }
73
104
  return [...out];
74
105
  }
@@ -88,10 +119,7 @@ export async function preHook(payload, request = devRequest) {
88
119
  const { agentId } = await config();
89
120
  let response;
90
121
  try {
91
- response = await request(
92
- "GET",
93
- `/api/v1/design/files/${encodeURIComponent(fileKey)}/lock/check?agentId=${encodeURIComponent(agentId)}`,
94
- );
122
+ response = await figmaLockCheck(fileKey, agentId, request);
95
123
  } catch (error) {
96
124
  throw new CliError(
97
125
  "FIGMA_CHECK_FAILED",
@@ -111,32 +139,44 @@ export async function preHook(payload, request = devRequest) {
111
139
  return { allowed: true, fileKey, changeSetId: response.data.changeSetId };
112
140
  }
113
141
 
114
- export async function postHook(payload, request = devRequest) {
142
+ export async function postHook(payload, request = devRequest, config = async () => (await import("./core.js")).readConfig()) {
115
143
  const name = payload.tool_name ?? "";
116
144
  if (!isFigmaTool(name) || isReadTool(name))
117
145
  return { logged: false, ignored: true };
118
146
  const fileKey = extractFileKey(payload.tool_input);
119
147
  if (!fileKey) return { logged: false, reason: "no file key" };
120
- const { agentId } = await (await import("./core.js")).readConfig();
121
- const checked = await request(
122
- "GET",
123
- `/api/v1/design/files/${encodeURIComponent(fileKey)}/lock/check?agentId=${encodeURIComponent(agentId)}`,
124
- );
148
+ const { agentId } = await config();
149
+ const checked = await figmaLockCheck(fileKey, agentId, request);
125
150
  if (!checked.data?.allowed || !checked.data.changeSetId)
126
151
  return { logged: false, reason: checked.data?.reason ?? "lock denied" };
127
- const nodeIds = extractNodeIds({
128
- input: payload.tool_input,
129
- response: payload.tool_response,
130
- });
152
+ const found = new Set();
153
+ extractNodeIds(payload.tool_input, found);
154
+ const nodeIds = extractNodeIds(payload.tool_response, found, { text: true });
131
155
  if (!nodeIds.length) nodeIds.push("unknown");
132
- await request(
133
- "POST",
134
- `/api/v1/design/change-sets/${encodeURIComponent(checked.data.changeSetId)}/writes`,
135
- { tool: name, nodeIds },
136
- );
156
+ try {
157
+ await request(
158
+ "POST",
159
+ `/api/v1/design/change-sets/${encodeURIComponent(checked.data.changeSetId)}/writes`,
160
+ { tool: name, nodeIds },
161
+ );
162
+ } catch (error) {
163
+ // Name the set in the fix even when Dev's details leave it out.
164
+ if (error?.code === "WRITE_OUT_OF_SCOPE")
165
+ throw new CliError(error.code, error.message, error.exitCode ?? 1, { ...error.details, changeSetId: error.details?.changeSetId ?? checked.data.changeSetId });
166
+ throw error;
167
+ }
137
168
  return { logged: true, changeSetId: checked.data.changeSetId, nodeIds };
138
169
  }
139
170
 
171
+ // What the post-hook says when logging a write fails. A write logged outside
172
+ // the set's scopes (MW1-57.7) exits 2, so Claude Code shows the agent why its
173
+ // submit will be refused; any other failure stays a note that blocks nothing.
174
+ export function postHookFailure(error) {
175
+ if (error?.code === "WRITE_OUT_OF_SCOPE")
176
+ return { exitCode: 2, message: `nodii-dev post hook: WRITE_OUT_OF_SCOPE: ${outOfScopeText(error.message, error.details)}` };
177
+ return { exitCode: 0, message: `nodii-dev post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}` };
178
+ }
179
+
140
180
  export const settingsSnippet = () => ({
141
181
  hooks: {
142
182
  PreToolUse: [
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.11",
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": {