@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 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. Per D1433, Codex remains read-only
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 { figmaChangeSetShow, figmaChangeSetText, figmaFileKey, figmaResubmit, notRegisteredHint } from "./figma.js";
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|check|log-write|submit|apply|published|abandon|release
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 devRequest(
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
- await devRequest(
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
- console.error(
694
- `nodii-dev post hook: ${error.code ?? "ERROR"}: ${error.message}`,
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.11";
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") {