@nodii/dev 0.6.11 → 0.6.12
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 +46 -3
- package/dist/cli.js +53 -33
- package/dist/core.js +1 -1
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +14 -1
- package/dist/figma.js +175 -1
- package/dist/hooks.js +26 -15
- 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,177 @@ 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
|
+
const FOUNDATIONS_ID = /^(?:S:|VariableID:|VariableCollectionId:)/;
|
|
154
|
+
|
|
155
|
+
/** Dev's WRITE_OUT_OF_SCOPE, with what to do about it (MW1-57.7). */
|
|
156
|
+
export function outOfScopeText(message, details, id = details?.changeSetId ?? "CHANGE_SET") {
|
|
157
|
+
const ids = details?.nodeIds ?? [];
|
|
158
|
+
if (details?.reason === "unknown-ids" || (ids.length && ids.every((x) => x === "unknown")))
|
|
159
|
+
return [
|
|
160
|
+
message,
|
|
161
|
+
`The write returned no node ids, so Dev can't check it against ${id}'s scopes.`,
|
|
162
|
+
`Return the ids you wrote from the script, then run: nodii-dev figma log-write ${id} --tool TOOL --nodes ID,ID`,
|
|
163
|
+
].join("\n");
|
|
164
|
+
const foundations = ids.filter((x) => FOUNDATIONS_ID.test(x));
|
|
165
|
+
return [
|
|
166
|
+
message,
|
|
167
|
+
...(ids.length ? [`Out of scope: ${ids.join(", ")}`] : []),
|
|
168
|
+
...(foundations.length
|
|
169
|
+
? [`Styles and variables (${foundations.join(", ")}) need the foundations scope: nodii-dev figma scope add ${id} --scope foundations`]
|
|
170
|
+
: []),
|
|
171
|
+
`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.`,
|
|
172
|
+
].join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Keep Dev's code and message; add what to do next.
|
|
176
|
+
function scopeRefusal(error, id) {
|
|
177
|
+
if (error?.code === "SCOPE_CONFLICT")
|
|
178
|
+
return new CliError(error.code, conflictText(error.message, error.details), error.exitCode ?? 1, error.details);
|
|
179
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
180
|
+
return new CliError(error.code, outOfScopeText(error.message, error.details, id), error.exitCode ?? 1, error.details);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A change-set route whose scope refusals say what to do next. */
|
|
185
|
+
const changeSetCall = (id, request) => async (method, path, body) => {
|
|
186
|
+
try {
|
|
187
|
+
return await request(method, path, body);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw scopeRefusal(error, id) ?? error;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** A file route that names the `figma file add` command for an unregistered file. */
|
|
194
|
+
const fileCall = (key, request) => async (method, path, body) => {
|
|
195
|
+
try {
|
|
196
|
+
return await request(method, path, body);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error?.code === "DESIGN_FILE_NOT_FOUND")
|
|
199
|
+
throw new CliError(error.code, notRegisteredHint(key), error.exitCode ?? 1, error.details);
|
|
200
|
+
throw scopeRefusal(error) ?? error;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** May this agent write to the file? Its own set, state and scopes (checkDesignFileLock). */
|
|
205
|
+
export const figmaLockCheck = (fileKey, agentId, request = devRequest) =>
|
|
206
|
+
request("GET", `/api/v1/design/files/${encode(fileKey)}/lock/check?agentId=${encode(agentId)}`);
|
|
207
|
+
|
|
208
|
+
/** Whether a lock check answers for this agent's own set (it holds one), not another agent's. */
|
|
209
|
+
export const holdsSet = (check) => Boolean(check?.changeSetId) && (check.allowed || check.reason === "change-set-not-editable");
|
|
210
|
+
|
|
211
|
+
const isWholeFile = (scopes) => !scopes?.length || scopes.some((s) => s.kind === "file");
|
|
212
|
+
|
|
213
|
+
export async function figmaLock(value, title, { scopes = [], request = devRequest } = {}) {
|
|
214
|
+
const key = figmaFileKey(value);
|
|
215
|
+
const result = await fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/lock`, {
|
|
216
|
+
changeSetTitle: title,
|
|
217
|
+
...(scopes.length ? { scopes } : {}),
|
|
218
|
+
});
|
|
219
|
+
// A Dev that doesn't know scopes ignores them and locks the whole file,
|
|
220
|
+
// which blocks every other lane: say so loudly instead of exiting 0.
|
|
221
|
+
if (scopes.length && isWholeFile(result?.data?.scopes))
|
|
222
|
+
throw new CliError(
|
|
223
|
+
"SCOPES_NOT_APPLIED",
|
|
224
|
+
`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"}.`,
|
|
225
|
+
1,
|
|
226
|
+
result?.data,
|
|
227
|
+
);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const figmaLockText = (cs) =>
|
|
232
|
+
[
|
|
233
|
+
`${cs.id} ${cs.state ?? "editing"} ${cs.title}`,
|
|
234
|
+
isWholeFile(cs.scopes)
|
|
235
|
+
? `Locked the whole of ${cs.fileKey}: this conflicts with every other change set on the file. Claim scopes instead with ${SCOPE_USAGE}.`
|
|
236
|
+
: `Holds ${cs.scopes.length} scope(s) in ${cs.fileKey}: ${cs.scopes.map(scopeText).join(", ")}`,
|
|
237
|
+
].join("\n");
|
|
238
|
+
|
|
239
|
+
export async function figmaScopeAdd(id, scopes, { request = devRequest } = {}) {
|
|
240
|
+
if (!id || !scopes.length) throw new CliError("USAGE", `figma scope add CHANGE_SET ${SCOPE_USAGE} [--scope ...]`, 2);
|
|
241
|
+
return changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const figmaSubmit = (id, payload, { request = devRequest } = {}) =>
|
|
245
|
+
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
|
|
246
|
+
|
|
247
|
+
export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {}) =>
|
|
248
|
+
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/writes`, { tool, nodeIds });
|
|
249
|
+
|
|
250
|
+
/** One publish for the file: every awaiting_publish set on it (MW1-57.8). */
|
|
251
|
+
export async function figmaFilePublished(value, payload, { request = devRequest } = {}) {
|
|
252
|
+
const key = figmaFileKey(value);
|
|
253
|
+
return fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/published`, payload);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 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);
|
|
258
|
+
|
|
259
|
+
export async function figmaPublishPlan(value, { request = devRequest } = {}) {
|
|
260
|
+
const key = figmaFileKey(value);
|
|
261
|
+
return fileCall(key, request)("GET", `/api/v1/design/files/${encode(key)}/publish-plan`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const planLine = (x) => ` ${x.nodeId ?? scopeText(x)} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
|
|
265
|
+
|
|
266
|
+
/** The publish plan as two groups: what to publish, and what to leave unchecked in Figma's publish dialog. */
|
|
267
|
+
export function figmaPublishPlanText(plan) {
|
|
268
|
+
const publish = plan.publish ?? [];
|
|
269
|
+
const leave = plan.leaveUnchecked ?? [];
|
|
270
|
+
return [
|
|
271
|
+
`Publish plan for ${plan.fileKey}${plan.sinceVersion ? ` (changes since version ${plan.sinceVersion})` : ""}`,
|
|
272
|
+
`publish (${publish.length}): sets awaiting publish`,
|
|
273
|
+
...(publish.length ? publish.map(planLine) : [" nothing awaiting publish"]),
|
|
274
|
+
`leave unchecked (${leave.length}): sets still editing or in review`,
|
|
275
|
+
...(leave.length ? leave.map(planLine) : [" none"]),
|
|
276
|
+
...(publish.length
|
|
277
|
+
? [`After publishing in Figma: nodii-dev figma published ${plan.fileKey} --figma-version VERSION`]
|
|
278
|
+
: []),
|
|
279
|
+
].join("\n");
|
|
280
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError, devRequest } from "./core.js";
|
|
2
|
+
import { figmaLockCheck, outOfScopeText } from "./figma.js";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { promisify } from "node:util";
|
|
4
5
|
|
|
@@ -88,10 +89,7 @@ export async function preHook(payload, request = devRequest) {
|
|
|
88
89
|
const { agentId } = await config();
|
|
89
90
|
let response;
|
|
90
91
|
try {
|
|
91
|
-
response = await request
|
|
92
|
-
"GET",
|
|
93
|
-
`/api/v1/design/files/${encodeURIComponent(fileKey)}/lock/check?agentId=${encodeURIComponent(agentId)}`,
|
|
94
|
-
);
|
|
92
|
+
response = await figmaLockCheck(fileKey, agentId, request);
|
|
95
93
|
} catch (error) {
|
|
96
94
|
throw new CliError(
|
|
97
95
|
"FIGMA_CHECK_FAILED",
|
|
@@ -111,17 +109,14 @@ export async function preHook(payload, request = devRequest) {
|
|
|
111
109
|
return { allowed: true, fileKey, changeSetId: response.data.changeSetId };
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
export async function postHook(payload, request = devRequest) {
|
|
112
|
+
export async function postHook(payload, request = devRequest, config = async () => (await import("./core.js")).readConfig()) {
|
|
115
113
|
const name = payload.tool_name ?? "";
|
|
116
114
|
if (!isFigmaTool(name) || isReadTool(name))
|
|
117
115
|
return { logged: false, ignored: true };
|
|
118
116
|
const fileKey = extractFileKey(payload.tool_input);
|
|
119
117
|
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
|
-
);
|
|
118
|
+
const { agentId } = await config();
|
|
119
|
+
const checked = await figmaLockCheck(fileKey, agentId, request);
|
|
125
120
|
if (!checked.data?.allowed || !checked.data.changeSetId)
|
|
126
121
|
return { logged: false, reason: checked.data?.reason ?? "lock denied" };
|
|
127
122
|
const nodeIds = extractNodeIds({
|
|
@@ -129,14 +124,30 @@ export async function postHook(payload, request = devRequest) {
|
|
|
129
124
|
response: payload.tool_response,
|
|
130
125
|
});
|
|
131
126
|
if (!nodeIds.length) nodeIds.push("unknown");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
try {
|
|
128
|
+
await request(
|
|
129
|
+
"POST",
|
|
130
|
+
`/api/v1/design/change-sets/${encodeURIComponent(checked.data.changeSetId)}/writes`,
|
|
131
|
+
{ tool: name, nodeIds },
|
|
132
|
+
);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// Name the set in the fix even when Dev's details leave it out.
|
|
135
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
136
|
+
throw new CliError(error.code, error.message, error.exitCode ?? 1, { ...error.details, changeSetId: error.details?.changeSetId ?? checked.data.changeSetId });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
137
139
|
return { logged: true, changeSetId: checked.data.changeSetId, nodeIds };
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// What the post-hook says when logging a write fails. A write logged outside
|
|
143
|
+
// the set's scopes (MW1-57.7) exits 2, so Claude Code shows the agent why its
|
|
144
|
+
// submit will be refused; any other failure stays a note that blocks nothing.
|
|
145
|
+
export function postHookFailure(error) {
|
|
146
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
147
|
+
return { exitCode: 2, message: `nodii-dev post hook: WRITE_OUT_OF_SCOPE: ${outOfScopeText(error.message, error.details)}` };
|
|
148
|
+
return { exitCode: 0, message: `nodii-dev post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}` };
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
export const settingsSnippet = () => ({
|
|
141
152
|
hooks: {
|
|
142
153
|
PreToolUse: [
|