@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/README.md
CHANGED
|
@@ -145,12 +145,14 @@ prints them without creating anything.
|
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
147
|
nodii-dev figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
|
|
148
|
-
nodii-dev figma lock FILE_KEY --title TEXT
|
|
148
|
+
nodii-dev figma lock FILE_KEY --title TEXT [--scope KIND:ID ...]
|
|
149
|
+
nodii-dev figma scope add CHANGE_SET --scope KIND:ID [--scope ...]
|
|
150
|
+
nodii-dev figma publish-plan FILE_KEY
|
|
149
151
|
nodii-dev figma check FILE_KEY
|
|
150
152
|
nodii-dev figma log-write CHANGE_SET --tool TOOL --nodes NODE[,NODE]
|
|
151
153
|
nodii-dev figma submit CHANGE_SET --file SUBMISSION.json
|
|
152
154
|
nodii-dev figma apply CHANGE_SET --figma-version VERSION
|
|
153
|
-
nodii-dev figma published CHANGE_SET --figma-version VERSION
|
|
155
|
+
nodii-dev figma published FILE_KEY|CHANGE_SET --figma-version VERSION
|
|
154
156
|
nodii-dev figma abandon CHANGE_SET
|
|
155
157
|
nodii-dev figma withdraw CHANGE_SET # take back a submit before anything is decided
|
|
156
158
|
nodii-dev figma resubmit CHANGE_SET PAYLOAD.json # round 2: re-submit every rejected item
|
|
@@ -170,6 +172,52 @@ that account). Registering a key Dev already has returns the stored row
|
|
|
170
172
|
unchanged. A figma.com file link works in place of the key. `figma lock` on
|
|
171
173
|
an unregistered file prints the `figma file add` command to run.
|
|
172
174
|
|
|
175
|
+
**Scoped locks (DEC-0032).** A change set locks scopes inside a file, not
|
|
176
|
+
the whole file, so lanes can change one file in parallel. Give `figma lock`
|
|
177
|
+
one `--scope` per scope:
|
|
178
|
+
|
|
179
|
+
| `--scope` | Claims |
|
|
180
|
+
| --- | --- |
|
|
181
|
+
| `component:NODE_ID` | a component set or standalone component, at its top-level node (a variant id is taken as its set) |
|
|
182
|
+
| `page:NODE_ID` | a whole Figma page: new screens, specimens, docs pages |
|
|
183
|
+
| `foundations` | the file's variables, text styles and effect styles; one holder at a time |
|
|
184
|
+
| `create:PAGE_ID:NAME` | a new component, made on that page |
|
|
185
|
+
|
|
186
|
+
A node id from a figma.com link (`12-34`) is read as Figma's `12:34`. Two
|
|
187
|
+
sets conflict only when their scopes overlap: the same node, a page that holds
|
|
188
|
+
a claimed component (or the reverse), or foundations already held. Dev
|
|
189
|
+
refuses an overlap with `SCOPE_CONFLICT`; the CLI prints Dev's message, then
|
|
190
|
+
the set holding the overlapping scope. `figma lock` without `--scope` locks the
|
|
191
|
+
whole file, as older CLIs do, and that conflicts with every other set.
|
|
192
|
+
`figma scope add` widens a set you hold, with the same check; nothing is ever
|
|
193
|
+
widened silently.
|
|
194
|
+
|
|
195
|
+
Every write must fall inside the set's scopes (MW1-57.7). The post-hook
|
|
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`,
|
|
201
|
+
the hook exits `2`, so Claude Code shows the agent why: a write that
|
|
202
|
+
returned no node ids (return them from the script, then run `figma log-write`),
|
|
203
|
+
or ids outside the scopes. Dev refuses `figma submit` and `figma resubmit`
|
|
204
|
+
with the same code until it's fixed. You can widen the scope with
|
|
205
|
+
`figma scope add` if it's free (styles and variables need
|
|
206
|
+
`--scope foundations`), or revert the node exactly to how it was at the set's
|
|
207
|
+
base version.
|
|
208
|
+
|
|
209
|
+
Scopes release when a set reaches `awaiting_publish`. One library publish
|
|
210
|
+
then covers every set awaiting publish. Before you publish in Figma,
|
|
211
|
+
`figma publish-plan FILE_KEY` lists the components changed since the last
|
|
212
|
+
publish in two groups, and warns about any component that sets from both
|
|
213
|
+
groups changed. **publish** belongs to sets awaiting publish. **leave
|
|
214
|
+
unchecked** belongs to sets still editing or in review: untick those in
|
|
215
|
+
Figma's publish dialog. Then `figma published FILE_KEY --figma-version VERSION`
|
|
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.
|
|
220
|
+
|
|
173
221
|
`figma withdraw` moves a change set you hold from `in_review` back to
|
|
174
222
|
`editing`, as long as no item has been decided (Dev's
|
|
175
223
|
`withdrawDesignChangeSet`). Dev refuses when the change set doesn't exist
|
|
@@ -442,6 +490,7 @@ exits 0 only when every check is `OK`. `--json` returns the same checks as
|
|
|
442
490
|
| `repo` | Whether the repository in this directory is the agent's home repository or granted to it. Repositories are compared by short name (`nodii-clis`, not `owner/nodii-clis`), as Dev compares them. Dev does not let an OAuth agent read its own grants yet (requested in Dev), so a repository other than the home one is `CANNOT CHECK`. |
|
|
443
491
|
| `lane_limit` | The agent's active lanes against its lane limit. A full limit is `OK` when a lane on this branch holds the slot. |
|
|
444
492
|
| `figma_file` | Only with `--figma-file KEY` (a key or figma.com link), or `NODII_DEV_FIGMA_FILE`: whether Dev has the brief's Figma file registered. `MISSING` names the `figma file add` command, since `figma lock` would fail with `DESIGN_FILE_NOT_FOUND`. |
|
|
493
|
+
| `figma_scopes` | With `figma_file`, when the file is registered: the change set this agent holds in the file and its lock scopes (or the whole file), or that it holds none (DEC-0032). |
|
|
445
494
|
|
|
446
495
|
Doctor may refresh the access token, as any command does. The permission table
|
|
447
496
|
is kept by hand from Dev's route policy until Dev's OpenAPI names each
|
|
@@ -542,5 +591,6 @@ and `figma log-write` follows the same rule (`CHANGE_SET_NOT_EDITABLE`). The pre
|
|
|
542
591
|
non-Figma tools and allowlisted Figma reads, but defaults to denying every
|
|
543
592
|
Figma write if the file key is absent, Dev cannot be reached, or the lock
|
|
544
593
|
is denied. It exits `2`, which makes Claude Code block the call. The post-hook
|
|
545
|
-
best-effort logs the write and never blocks
|
|
594
|
+
best-effort logs the write and never blocks; it exits `2` only for
|
|
595
|
+
`WRITE_OUT_OF_SCOPE`, after the write is logged, so the agent sees it. Per D1433, Codex remains read-only
|
|
546
596
|
for Figma; this package does not write to Figma itself.
|
package/dist/cli.js
CHANGED
|
@@ -22,11 +22,30 @@ import {
|
|
|
22
22
|
deviceLogin,
|
|
23
23
|
revokeOAuth,
|
|
24
24
|
} from "./oauth.js";
|
|
25
|
-
import { heartbeatCurrentLane, postHook, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
|
|
25
|
+
import { heartbeatCurrentLane, postHook, postHookFailure, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
|
|
26
26
|
import { setupCommand } from "./setup.js";
|
|
27
27
|
import { harnessCommand } from "./harness.js";
|
|
28
28
|
import { doctorCommand } from "./doctor.js";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
figmaChangeSetShow,
|
|
31
|
+
figmaChangeSetText,
|
|
32
|
+
figmaFileKey,
|
|
33
|
+
figmaFilePublished,
|
|
34
|
+
figmaFilePublishedText,
|
|
35
|
+
figmaLock,
|
|
36
|
+
figmaLockCheck,
|
|
37
|
+
figmaLockText,
|
|
38
|
+
figmaLogWrite,
|
|
39
|
+
figmaPublishPlan,
|
|
40
|
+
figmaPublishPlanText,
|
|
41
|
+
figmaResubmit,
|
|
42
|
+
figmaScopeAdd,
|
|
43
|
+
figmaSubmit,
|
|
44
|
+
isChangeSetId,
|
|
45
|
+
scopeText,
|
|
46
|
+
scopesFrom,
|
|
47
|
+
} from "./figma.js";
|
|
48
|
+
export { figmaLock } from "./figma.js";
|
|
30
49
|
import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
|
|
31
50
|
import { changeSetCommand, guidelineCommand } from "./guidelines.js";
|
|
32
51
|
import {
|
|
@@ -69,7 +88,13 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
69
88
|
design guideline list [--drafted] [--all] | show ENTRY
|
|
70
89
|
design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]
|
|
71
90
|
design changeset list [--entry KEY] [--state STATE] | show ID
|
|
72
|
-
figma lock
|
|
91
|
+
figma lock FILE_KEY --title TEXT [--scope KIND:ID ...]
|
|
92
|
+
# scopes: component:NODE page:NODE foundations create:PAGE:NAME;
|
|
93
|
+
# no --scope locks the whole file (DEC-0032)
|
|
94
|
+
figma scope add CHANGE_SET --scope KIND:ID [--scope ...] # widen a held set's scopes
|
|
95
|
+
figma publish-plan FILE_KEY # what to publish, and what to leave unchecked in Figma's dialog
|
|
96
|
+
figma published FILE_KEY|CHANGE_SET --figma-version V # a file key marks every awaiting set published
|
|
97
|
+
figma check|log-write|submit|apply|abandon|release
|
|
73
98
|
figma withdraw CHANGE_SET # holder takes back a submit: in_review -> editing (or a re-submit -> applying)
|
|
74
99
|
figma resubmit CHANGE_SET PAYLOAD.json
|
|
75
100
|
# holder re-submits every rejected item: applying -> in_review, round + 1
|
|
@@ -321,16 +346,6 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
|
|
|
321
346
|
}
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
export async function figmaLock(value, title, { request = devRequest } = {}) {
|
|
325
|
-
const key = figmaFileKey(value);
|
|
326
|
-
try {
|
|
327
|
-
return await request("POST", `/api/v1/design/files/${encode(key)}/lock`, { changeSetTitle: title });
|
|
328
|
-
} catch (error) {
|
|
329
|
-
if (error?.code !== "DESIGN_FILE_NOT_FOUND") throw error;
|
|
330
|
-
throw new CliError(error.code, notRegisteredHint(key), error.exitCode ?? 1, error.details);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
349
|
export async function main(argv = process.argv.slice(2)) {
|
|
335
350
|
if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
|
|
336
351
|
return console.log(VERSION);
|
|
@@ -589,7 +604,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
589
604
|
}
|
|
590
605
|
if (command === "figma") {
|
|
591
606
|
if (action === "lock")
|
|
592
|
-
return emit(await figmaLock(rest[0], requireFlag(f, "title")), json);
|
|
607
|
+
return emit(await figmaLock(rest[0], requireFlag(f, "title"), { scopes: scopesFrom(f) }), json, (p) => figmaLockText(p.data));
|
|
608
|
+
if (action === "scope") {
|
|
609
|
+
if (rest[0] !== "add" || !rest[1] || rest.length > 2) throw new CliError("USAGE", "figma scope add CHANGE_SET --scope KIND:ID [--scope ...]", 2);
|
|
610
|
+
return emit(await figmaScopeAdd(rest[1], scopesFrom(f)), json, (p) =>
|
|
611
|
+
`${rest[1]} holds ${p.data?.scopes?.length ?? "its"} scope(s)${p.data?.scopes?.length ? `: ${p.data.scopes.map(scopeText).join(", ")}` : ""}`);
|
|
612
|
+
}
|
|
613
|
+
if (action === "publish-plan") {
|
|
614
|
+
if (!rest[0] || rest.length > 1) throw new CliError("USAGE", "figma publish-plan FILE_KEY", 2);
|
|
615
|
+
return emit(await figmaPublishPlan(rest[0]), json, (p) => figmaPublishPlanText(p.data));
|
|
616
|
+
}
|
|
617
|
+
// One publish covers every set awaiting publish on the file (MW1-57.8); a
|
|
618
|
+
// change set id keeps the per-set route.
|
|
619
|
+
if (action === "published" && rest[0] && !isChangeSetId(rest[0]))
|
|
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));
|
|
621
|
+
if (action === "submit") {
|
|
622
|
+
const payload = f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") };
|
|
623
|
+
return emit(await figmaSubmit(rest[0], payload), json);
|
|
624
|
+
}
|
|
593
625
|
if (action === "file") {
|
|
594
626
|
if (rest[0] !== "add") throw new CliError("USAGE", "figma file add FILE_KEY --name TEXT --kind KIND [--module M]", 2);
|
|
595
627
|
return emit(await figmaFileAdd(rest[1], f), json, (p) =>
|
|
@@ -597,10 +629,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
597
629
|
}
|
|
598
630
|
if (action === "check") {
|
|
599
631
|
const c = await readConfig();
|
|
600
|
-
const result = await
|
|
601
|
-
"GET",
|
|
602
|
-
`/api/v1/design/files/${encode(rest[0])}/lock/check?agentId=${encode(c.agentId)}`,
|
|
603
|
-
);
|
|
632
|
+
const result = await figmaLockCheck(figmaFileKey(rest[0]), c.agentId);
|
|
604
633
|
emit(
|
|
605
634
|
result,
|
|
606
635
|
json,
|
|
@@ -611,18 +640,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
611
640
|
return;
|
|
612
641
|
}
|
|
613
642
|
if (action === "log-write")
|
|
614
|
-
return emit(
|
|
615
|
-
|
|
616
|
-
"POST",
|
|
617
|
-
`/api/v1/design/change-sets/${encode(rest[0])}/writes`,
|
|
618
|
-
{
|
|
619
|
-
tool: requireFlag(f, "tool"),
|
|
620
|
-
nodeIds: requireFlag(f, "nodes").split(",").filter(Boolean),
|
|
621
|
-
},
|
|
622
|
-
),
|
|
623
|
-
json,
|
|
624
|
-
);
|
|
625
|
-
if (["submit", "apply", "published", "abandon"].includes(action)) {
|
|
643
|
+
return emit(await figmaLogWrite(rest[0], requireFlag(f, "tool"), requireFlag(f, "nodes").split(",").filter(Boolean)), json);
|
|
644
|
+
if (["apply", "published", "abandon"].includes(action)) {
|
|
626
645
|
const payload = f.file
|
|
627
646
|
? JSON.parse(await readFile(f.file, "utf8"))
|
|
628
647
|
: action === "abandon"
|
|
@@ -650,7 +669,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
650
669
|
return emit(
|
|
651
670
|
await devRequest(
|
|
652
671
|
"POST",
|
|
653
|
-
`/api/v1/design/files/${encode(rest[0])}/lock/release`,
|
|
672
|
+
`/api/v1/design/files/${encode(figmaFileKey(rest[0]))}/lock/release`,
|
|
654
673
|
{},
|
|
655
674
|
),
|
|
656
675
|
json,
|
|
@@ -690,9 +709,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
690
709
|
try {
|
|
691
710
|
return emit(await postHook(payload), json, "logged");
|
|
692
711
|
} catch (error) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
);
|
|
712
|
+
const outcome = postHookFailure(error);
|
|
713
|
+
console.error(outcome.message);
|
|
714
|
+
if (outcome.exitCode) process.exitCode = outcome.exitCode;
|
|
696
715
|
return;
|
|
697
716
|
}
|
|
698
717
|
}
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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") {
|