@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/README.md +53 -3
- package/dist/cli.js +53 -34
- package/dist/core.js +32 -10
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +14 -1
- package/dist/figma.js +209 -1
- package/dist/hooks.js +68 -28
- package/dist/oauth.js +21 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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;
|