@nodii/dev 0.6.17 → 0.6.21

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
@@ -12,9 +12,30 @@ Linux only (the launcher is a POSIX shell script).
12
12
  nodii-dev login # opens the approval page in your browser
13
13
  nodii-dev login --no-browser # prints the link and code instead
14
14
  nodii-dev login --scope devops.issues.read,devops.issues.write # narrower grant
15
+ nodii-dev login --force # replace the machine's sign-in (never from a lane)
15
16
  nodii-dev agent register --name my-agent
16
17
  ```
17
18
 
19
+ A machine has **one sign-in**. The refresh credential is kept in the keychain
20
+ under the OAuth client, not beside a config file, so every config path on the
21
+ machine (`NODII_DEV_CONFIG_PATH`) shares it. `login` checks it first: when the
22
+ shared access token is live or the refresh credential refreshes, it prints
23
+ "You're already signed in as PERSON on this machine." and exits 0 without a
24
+ device flow, from any config path, an empty or new one included (it writes
25
+ the routing there, without an agent). A sign-in nodii-auth no longer accepts
26
+ (`invalid_grant`), or a refresh it refuses outright, is named, then replaced
27
+ by a device flow. If nodii-auth cannot be reached, or the keychain cannot be
28
+ read, `login` stops: that says nothing about the sign-in. A different
29
+ `--scope` replaces the one sign-in, so with a valid one it needs `--force`.
30
+ `login --force` starts a device flow whatever the machine holds and names the
31
+ person it replaces. A lane's config (`lane-*.json`) never starts a device
32
+ flow (`--force`, or `login` over a dead sign-in: the coordinator signs in) and
33
+ refuses `logout`; both end the one sign-in for every lane.
34
+ `agent register` on a fresh, empty config path takes the machine's sign-in the
35
+ same way (the OAuth client and hosts from the machine's newest config, the
36
+ person and installation from the token; `NODII_DEV_*` variables override),
37
+ so a new lane needs no login and no hand-seeded config.
38
+
18
39
  `nodii-dev` talks only to Dev, through the platform edge
19
40
  (`https://platform.nucleus-cloud.in/devops/*`), signed in as the platform
20
41
  tenant's nodii-dev OAuth app (D1368). There is no other host and no other way
@@ -62,7 +83,7 @@ Dev failures print Dev's error code to stderr and exit non-zero.
62
83
  ## Commands
63
84
 
64
85
  ```text
65
- nodii-dev login [--no-browser] [--scope KEYS]
86
+ nodii-dev login [--no-browser] [--scope KEYS] [--force]
66
87
  nodii-dev logout
67
88
  nodii-dev whoami # OAuth identity plus the registered agent
68
89
  nodii-dev config show
@@ -82,7 +103,8 @@ nodii-dev issues list [--service ID] [--project KEY] [--status STATUS]
82
103
  nodii-dev issues show ISSUE
83
104
  nodii-dev issues create --service ID --title TEXT [--body TEXT|--body-file FILE]
84
105
  [--kind KIND] [--priority PRIORITY] [--project KEY]
85
- nodii-dev issues update ISSUE [the same mutable fields]
106
+ nodii-dev issues update ISSUE [the same mutable fields] [--comment TEXT|--comment-file FILE]
107
+ nodii-dev issues comment ISSUE --body TEXT|--body-file FILE # commentOnIssue, on the issue's activity feed
86
108
  nodii-dev issues claim ISSUE [--ttl MINUTES]
87
109
  nodii-dev issues release ISSUE [--note TEXT]
88
110
  nodii-dev issues transition ISSUE --to todo|in_progress|in_review|blocked|done|wont_do
@@ -280,7 +302,9 @@ nodii-dev hook print-settings # prints managed-settings JSON; writes not
280
302
 
281
303
  nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
282
304
  --side backend|frontend|both [--repo REPO] [--issue ISSUE]
283
- nodii-dev lane step KEY --side backend|frontend --start|--done|--block STEP
305
+ nodii-dev lane step KEY --side backend|frontend --start|--done|--block STEP [--reason TEXT]
306
+ # --reason is kept as a lane note; on a step already blocked,
307
+ # --block --reason records the new reason (Dev issue 125 moves it onto the step)
284
308
  nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF
285
309
  nodii-dev lane heartbeat KEY [--note TEXT]
286
310
  nodii-dev lane review KEY
@@ -402,6 +426,29 @@ version. A set never submitted needs `--file`: the body `figma submit --file`
402
426
  would send. A cold run can outlast the edge's gateway; the CLI then says
403
427
  `DEV_TIMEOUT` and to run it again (Dev keeps the mains it read for 5 minutes).
404
428
 
429
+ ### Large sets: submit as a Dev job
430
+
431
+ `figma submit`, `figma resubmit` and `design check --change-set` send
432
+ `Prefer: respond-async`. Dev answers 202 with a job, and the CLI polls it
433
+ (`getDesignSubmitJob`) with backoff, printing progress on stderr, until it
434
+ ends. The output and exit code are the synchronous call's: a succeeded job
435
+ prints Dev's 200 body and warnings, a failed one its error and findings. A
436
+ large set's gate outlasts the edge's 30 s timeout, so this is the default.
437
+
438
+ - `a submit is already running: JOB`: Dev already runs a submit (or a dry run)
439
+ for this set (409 `SUBMIT_IN_PROGRESS` / `CONFORMANCE_IN_PROGRESS`). The CLI
440
+ polls that job instead of sending again, then exits 1 with the 409's code
441
+ and that job's outcome (`--json`: `details.result` or `details.error`):
442
+ this command's body was not sent, so read the set and run it again if it
443
+ still needs to go.
444
+ - `SUBMIT_JOB_LOST`: Dev restarted while the job ran. Run the same command again.
445
+ - `SUBMIT_JOB_UNREACHABLE`: polling lost the edge; the job runs on in Dev.
446
+ Read the set with `figma changeset show` before sending it again.
447
+ - `--sync` sends the old single request. If it (or a submit's 202) meets the
448
+ edge's 502 or 504, the CLI reads the set and says whether the submit landed;
449
+ it never resends. `SUBMIT_UNCONFIRMED` means it hasn't yet: check with
450
+ `figma changeset show` before submitting again.
451
+
405
452
  When `figma submit`, `figma resubmit` or the dry run hits
406
453
  `DS_CONFORMANCE_FAILED`, the CLI prints each finding and Dev's warnings. A
407
454
  submit Dev accepts prints its warnings too. On a stale version it also prints
package/dist/cli.js CHANGED
@@ -15,11 +15,9 @@ import {
15
15
  writeConfig,
16
16
  } from "./core.js";
17
17
  import {
18
- DEFAULT_AUTH_URL,
19
- DEFAULT_CLIENT_ID,
20
- DEFAULT_EDGE_URL,
21
- PLATFORM_TENANT,
22
- deviceLogin,
18
+ adoptMachineSignIn,
19
+ login as signIn,
20
+ refuseFromLane,
23
21
  revokeOAuth,
24
22
  } from "./oauth.js";
25
23
  import { heartbeatCurrentLane, postHook, postHookFailure, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
@@ -43,12 +41,13 @@ import {
43
41
  figmaScopeSet,
44
42
  figmaScopeSetText,
45
43
  figmaSubmit,
44
+ submitMode,
46
45
  isChangeSetId,
47
46
  scopeText,
48
47
  scopesFrom,
49
48
  conformanceLines,
50
49
  } from "./figma.js";
51
- export { figmaLock } from "./figma.js";
50
+ export { figmaLock, submitMode } from "./figma.js";
52
51
  import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
53
52
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
54
53
  import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
@@ -78,11 +77,14 @@ const HELP = `nodii-dev ${VERSION}
78
77
 
79
78
  Usage: nodii-dev <command> [options] [--json]
80
79
 
81
- login | logout | whoami | config show
80
+ login [--force] | logout | whoami | config show
81
+ # login says so when the machine is already signed in; --force
82
+ # replaces that one sign-in (never from a lane)
82
83
  doctor [--figma-file KEY] [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots
83
84
  # and, given a key (or NODII_DEV_FIGMA_FILE), that Figma file's registration
84
85
  agent register --name NAME | agent heartbeat
85
86
  issues list|show|create|update|claim|release
87
+ issues comment ISSUE --body TEXT # or --body-file FILE; issues update --comment TEXT|--comment-file FILE adds one too
86
88
  issues transition ISSUE --to STATUS [--reason TEXT] [--dev-check REF]
87
89
  # blocked and wont_do need --reason; done needs --dev-check
88
90
  inbox list|show
@@ -96,7 +98,7 @@ Usage: nodii-dev <command> [options] [--json]
96
98
  design check FILE_KEY|LINK [--nodes A,B] [--roles R,S] [--library]
97
99
  # the DS conformance check Dev's submit runs, before you submit;
98
100
  # reads Figma with FIGMA_PAT; exit 1 = DS_CONFORMANCE_FAILED or an error (--json: data.passed)
99
- design check --change-set CS [--file SUBMIT.json] [--figma-version V]
101
+ design check --change-set CS [--file SUBMIT.json] [--figma-version V] [--sync]
100
102
  # Dev's dry run of the submit gate on a set, with the image guard;
101
103
  # a stale version is a warning that prints when to retry
102
104
  figma lock FILE_KEY --title TEXT [--scope KIND:ID ...]
@@ -107,8 +109,12 @@ Usage: nodii-dev <command> [options] [--json]
107
109
  figma publish-plan FILE_KEY # what to publish, and what to leave unchecked in Figma's dialog
108
110
  figma published FILE_KEY|CHANGE_SET --figma-version V # a file key marks every awaiting set published
109
111
  figma check|log-write|submit|apply|abandon|release
112
+ figma submit CHANGE_SET --file SUBMIT.json [--sync]
113
+ # submit, resubmit and design check --change-set run as a job in Dev
114
+ # and poll it (a large set outlasts the edge's timeout); --sync waits
115
+ # on one request instead. A 502/504 is checked against the set, never resent
110
116
  figma withdraw CHANGE_SET # holder takes back a submit: in_review -> editing (or a re-submit -> applying)
111
- figma resubmit CHANGE_SET PAYLOAD.json
117
+ figma resubmit CHANGE_SET PAYLOAD.json [--sync]
112
118
  # holder re-submits every rejected item: applying -> in_review, round + 1
113
119
  figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
114
120
  figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
@@ -129,7 +135,8 @@ Usage: nodii-dev <command> [options] [--json]
129
135
  rules cap TOKENS # operator: set the core token cap (default 3000)
130
136
 
131
137
  lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
132
- lane step <key> --side SIDE --start|--done|--block <step>
138
+ lane step <key> --side SIDE --start|--done|--block <step> [--reason TEXT]
139
+ # --block --reason on a blocked step records its new reason
133
140
  lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
134
141
  lane heartbeat|review|land|verify|prioritize|deprioritize|stop <key>
135
142
  lane non-issue <key> --reason TEXT --evidence TEXT --reroute KEY[,KEY]|none
@@ -194,18 +201,22 @@ async function login(flags, json) {
194
201
  "nodii-dev talks only to Dev, through the platform edge, signed in as the platform tenant's OAuth app. Run `nodii-dev login`.",
195
202
  2,
196
203
  );
197
- const previous = await readConfig(false);
198
- const result = await deviceLogin({
199
- clientId: flags["client-id"] ?? process.env.NODII_DEV_OAUTH_CLIENT_ID ?? previous?.clientId ?? DEFAULT_CLIENT_ID,
200
- authUrl: flags["auth-url"] ?? process.env.NODII_DEV_AUTH_URL ?? DEFAULT_AUTH_URL,
201
- edgeUrl: flags["edge-url"] ?? process.env.NODII_DEV_EDGE_URL ?? DEFAULT_EDGE_URL,
202
- tenantHint: flags["tenant-hint"] ?? process.env.NODII_DEV_PLATFORM_TENANT_ID ?? PLATFORM_TENANT,
204
+ const result = await signIn({
205
+ force: flags.force === true,
206
+ previous: await readConfig(false),
207
+ clientId: flags["client-id"] ?? process.env.NODII_DEV_OAUTH_CLIENT_ID,
208
+ authUrl: flags["auth-url"] ?? process.env.NODII_DEV_AUTH_URL,
209
+ edgeUrl: flags["edge-url"] ?? process.env.NODII_DEV_EDGE_URL,
210
+ tenantHint: flags["tenant-hint"] ?? process.env.NODII_DEV_PLATFORM_TENANT_ID,
203
211
  noBrowser: Boolean(flags["no-browser"]),
204
212
  scope: flags.scope,
205
213
  output: json ? console.error : console.log,
206
- previous,
207
214
  });
208
- return emit({ loggedIn: true, provider: "oauth", credentialStorage: result.storage, agentId: result.agentId }, json,
215
+ if (result.alreadySignedIn)
216
+ return emit({ loggedIn: true, alreadySignedIn: true, provider: "oauth", person: result.person, agentId: result.agentId }, json,
217
+ `You're already signed in${result.person ? ` as ${result.person}` : ""} on this machine.${result.agentId ? "" : " Run `nodii-dev agent register --name NAME` next."}`);
218
+ return emit({ loggedIn: true, alreadySignedIn: false, provider: "oauth", person: result.person, credentialStorage: result.storage, agentId: result.agentId,
219
+ ...(result.replaced !== undefined ? { replaced: result.replaced } : {}), ...(result.replacedDeadSignIn ? { replacedDeadSignIn: result.replacedDeadSignIn } : {}) }, json,
209
220
  result.agentId
210
221
  ? `OAuth login complete; refresh credential stored in ${result.storage}. Kept agent registration ${result.agentId}.`
211
222
  : `OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
@@ -232,6 +243,18 @@ async function issues(action, args, f, json) {
232
243
  if (action === "show")
233
244
  return emit(await devRequest("GET", `/api/v1/issues/${encode(ref)}`), json);
234
245
  const config = await readConfig();
246
+ // commentOnIssue. `issues update --comment` sent no field PATCH accepts (HTTP 400): a comment is its own route.
247
+ const commentBody = async (name) => {
248
+ const body = await bodyFlag(f, name);
249
+ if (typeof body !== "string" || !body.trim())
250
+ throw new CliError("USAGE", `issues ${action} ISSUE --${name} TEXT (or --${name}-file FILE)`, 2);
251
+ if (body.length > COMMENT_MAX) throw new CliError("USAGE", `The comment is ${body.length} characters; Dev keeps at most ${COMMENT_MAX}.`, 2);
252
+ return body;
253
+ };
254
+ const comment = (key, body) => devRequest("POST", `/api/v1/issues/${encode(key)}/comment`, { body, onBehalfOf: config.person });
255
+ if (action === "comment") return emit(await comment(ref, await commentBody("body")), json, `${ref}: comment added.`);
256
+ // Read and checked before anything is sent, so a bad comment changes nothing.
257
+ const note = ["create", "update"].includes(action) && (f.comment !== undefined || f["comment-file"] !== undefined) ? await commentBody("comment") : undefined;
235
258
  if (action === "create" || action === "update") {
236
259
  const bodyMd = await bodyFlag(f);
237
260
  const payload = {
@@ -252,11 +275,23 @@ async function issues(action, args, f, json) {
252
275
  ].filter(([, v]) => v !== undefined),
253
276
  ),
254
277
  };
278
+ // An update with nothing to PATCH but a comment is only the comment.
279
+ if (action === "update" && note !== undefined && Object.keys(payload).every((k) => k === "onBehalfOf"))
280
+ return emit(await comment(ref, note), json, `${ref}: comment added.`);
255
281
  const result = await devRequest(
256
282
  action === "create" ? "POST" : "PATCH",
257
283
  action === "create" ? "/api/v1/issues" : `/api/v1/issues/${encode(ref)}`,
258
284
  payload,
259
285
  );
286
+ // After the write, so a refused create or update leaves no comment behind.
287
+ if (note !== undefined) {
288
+ const key = result.data?.key ?? ref;
289
+ try {
290
+ await comment(key, note);
291
+ } catch (error) {
292
+ throw new CliError("COMMENT_FAILED", `${key} was ${action}d, but the comment was not posted (${error?.code ?? "error"}: ${error?.message ?? error}). Post it with \`nodii-dev issues comment ${key} --body TEXT\`.`, 1, { issue: result.data ?? null });
293
+ }
294
+ }
260
295
  return emit(
261
296
  result,
262
297
  json,
@@ -291,6 +326,8 @@ async function issues(action, args, f, json) {
291
326
  throw new CliError("USAGE", `Unknown issues action: ${action}`, 2);
292
327
  }
293
328
 
329
+ // commentOnIssue's body limit.
330
+ const COMMENT_MAX = 10_000;
294
331
  const ISSUE_STATUSES = ["todo", "in_progress", "in_review", "blocked", "done", "wont_do"];
295
332
  // A blocked reason names who or what clears it: "cleared by", "unblocks", "waiting on", "until".
296
333
  const CLEARED_BY = /\b(clear(s|ed)?|unblock(s|ed)?|wait(s|ing)? (on|for)|until)\b/i;
@@ -386,9 +423,19 @@ export async function main(argv = process.argv.slice(2)) {
386
423
  }
387
424
  if (command === "login") return login(f, json);
388
425
  if (command === "logout") {
426
+ refuseFromLane("logout");
389
427
  const c = await readConfig(false);
390
428
  if (c) {
391
- try { await revokeOAuth(c); } finally { await deleteCredentials(); }
429
+ try {
430
+ await revokeOAuth(c);
431
+ } catch (error) {
432
+ // A keychain that cannot be read still holds the credential: keep the
433
+ // config, so logout can revoke it once the keychain is unlocked.
434
+ if (error?.code === "KEYCHAIN_UNAVAILABLE") throw error;
435
+ await deleteCredentials();
436
+ throw error;
437
+ }
438
+ await deleteCredentials();
392
439
  } else await deleteCredentials();
393
440
  return emit({ loggedOut: true }, json, "Logged out.");
394
441
  }
@@ -428,7 +475,7 @@ export async function main(argv = process.argv.slice(2)) {
428
475
  if (command === "design" && action === "roles") return emit(await designRoles(rest), json, designRolesText);
429
476
  if (command === "design" && action === "check") {
430
477
  if ((!rest[0] && f["change-set"] === undefined) || rest.length > 1)
431
- throw new CliError("USAGE", "design check FILE_KEY|LINK [--nodes A,B] [--roles R,S] [--library] | design check --change-set CS [--file SUBMIT.json] [--figma-version V]", 2);
478
+ throw new CliError("USAGE", "design check FILE_KEY|LINK [--nodes A,B] [--roles R,S] [--library] | design check --change-set CS [--file SUBMIT.json] [--figma-version V] [--sync]", 2);
432
479
  const result = await designCheck(rest[0], f);
433
480
  emit(result.payload, json, result.human ?? designCheckText);
434
481
  if (result.exitCode) {
@@ -480,13 +527,15 @@ export async function main(argv = process.argv.slice(2)) {
480
527
  if (command === "agent") {
481
528
  if (["lane-limit", "grant", "revoke"].includes(action))
482
529
  return emitCommand(await agentWorkflowCommand(action, rest, f));
483
- const c = await readConfig();
530
+ // A fresh, empty config path takes the machine's sign-in (Dev issue 110).
531
+ const name = action === "register" ? requireFlag(f, "name") : undefined;
532
+ const c = action === "register" ? (await readConfig(false)) ?? (await adoptMachineSignIn()) : await readConfig();
484
533
  if (action === "register") {
485
534
  // POST /api/v1/agents is the registration route the edge exposes; Dev
486
535
  // binds the agent to the OAuth person, app and installation itself.
487
536
  const result = await devRequest("POST", "/api/v1/agents", {
488
537
  sessionId: f.session ?? crypto.randomUUID(),
489
- name: requireFlag(f, "name"),
538
+ name,
490
539
  });
491
540
  await writeConfig({ ...c, agentId: result.data.id });
492
541
  // Dev ignores a self-claimed service: staff bind agents to services
@@ -646,7 +695,7 @@ export async function main(argv = process.argv.slice(2)) {
646
695
  if (action === "submit") {
647
696
  const payload = f.file ? JSON.parse(await readFile(f.file, "utf8")) : { figmaVersion: requireFlag(f, "figma-version") };
648
697
  // A 200 carries top-level warnings (STALE_FIGMA_VERSION): print them.
649
- return emit(await figmaSubmit(rest[0], payload), json, (p) =>
698
+ return emit(await figmaSubmit(rest[0], payload, { sync: submitMode(f) }), json, (p) =>
650
699
  [JSON.stringify(p.data, null, 2), ...conformanceLines({ warnings: p.warnings })].join("\n"));
651
700
  }
652
701
  if (action === "file") {
@@ -686,7 +735,7 @@ export async function main(argv = process.argv.slice(2)) {
686
735
  if (action === "withdraw")
687
736
  return emit(await figmaWithdraw(rest[0]), json, (p) => `Withdrew ${rest[0]}; it is back in ${p.data?.state ?? "editing"}.`);
688
737
  if (action === "resubmit")
689
- return emit(await figmaResubmit(rest[0], rest[1]), json, (p) =>
738
+ return emit(await figmaResubmit(rest[0], rest[1], { sync: submitMode(f) }), json, (p) =>
690
739
  [`Re-submitted ${rest[0]}; it is ${p.data.state}.`, figmaChangeSetText(p.data), ...conformanceLines({ warnings: p.warnings })].join("\n"));
691
740
  if (action === "changeset") {
692
741
  if (rest[0] !== "show" || !rest[1] || rest.length > 2) throw new CliError("USAGE", "figma changeset show CHANGE_SET", 2);
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.17";
7
+ export const VERSION = "0.6.21";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -33,7 +33,13 @@ export const isOAuthConfig = (config) =>
33
33
  export async function readConfig(required = true) {
34
34
  let config;
35
35
  try {
36
- config = JSON.parse(await readFile(configPath(), "utf8"));
36
+ const text = await readFile(configPath(), "utf8");
37
+ // An empty file is a config path made ready for a new agent, not a login.
38
+ if (!text.trim()) {
39
+ if (!required) return null;
40
+ throw new Error("empty");
41
+ }
42
+ config = JSON.parse(text);
37
43
  } catch (error) {
38
44
  if (!required && error?.code === "ENOENT") return null;
39
45
  throw new CliError("NOT_LOGGED_IN", "Run `nodii-dev login` first.", 2);
@@ -113,7 +119,7 @@ const staleCode = (raw) => {
113
119
  }
114
120
  };
115
121
 
116
- async function devFetch(method, path, bodyText, contentType, accept) {
122
+ async function devFetch(method, path, bodyText, contentType, accept, extra = {}) {
117
123
  const config = await readConfig();
118
124
  const auth = await authProvider();
119
125
  const url = requestUrl(config, path);
@@ -124,7 +130,7 @@ async function devFetch(method, path, bodyText, contentType, accept) {
124
130
  let refreshed = false;
125
131
  let adopted = false;
126
132
  for (;;) {
127
- const headers = { accept };
133
+ const headers = { accept, ...extra };
128
134
  if (bodyText !== undefined) headers["content-type"] = contentType;
129
135
  Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
130
136
  let response;
@@ -143,8 +149,9 @@ async function devFetch(method, path, bodyText, contentType, accept) {
143
149
  }
144
150
  }
145
151
 
146
- export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
147
- const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json");
152
+ /** A JSON call that keeps the status: `{ status, payload }`. Errors throw as devRawRequest's do. */
153
+ export async function devExchange(method, path, bodyText, { contentType = "application/json", headers = {} } = {}) {
154
+ const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json", headers);
148
155
  let payload;
149
156
  try {
150
157
  payload = raw ? JSON.parse(raw) : {};
@@ -158,7 +165,11 @@ export async function devRawRequest(method, path, bodyText, contentType = "appli
158
165
  1,
159
166
  payload.details,
160
167
  );
161
- return payload;
168
+ return { status: response.status, payload };
169
+ }
170
+
171
+ export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
172
+ return (await devExchange(method, path, bodyText, { contentType })).payload;
162
173
  }
163
174
 
164
175
  /** A Dev route that answers with text (e.g. a document body as text/markdown). Errors are Dev's JSON. */
@@ -173,6 +184,10 @@ export async function devTextRequest(method, path) {
173
184
  export const devRequest = (method, path, body) =>
174
185
  devRawRequest(method, path, body === undefined ? undefined : JSON.stringify(body));
175
186
 
187
+ /** A JSON call sent with `Prefer: respond-async` (RFC 7240): `{ status, payload }`, 202 when Dev took it as a job. */
188
+ export const devAsyncRequest = async (method, path, body) =>
189
+ devExchange(method, path, body === undefined ? undefined : JSON.stringify(body), { headers: { prefer: "respond-async" } });
190
+
176
191
  /** Proves the stored OAuth login works: a token refresh, then the agent read once registered. */
177
192
  export async function checkLogin() {
178
193
  const config = await readConfig();
@@ -184,7 +199,7 @@ export async function checkLogin() {
184
199
  export const encode = (value) => encodeURIComponent(value);
185
200
  // Switches never take the next token as their value, so
186
201
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
187
- const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library"]);
202
+ const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force"]);
188
203
  export function parseArgs(argv) {
189
204
  const flags = {};
190
205
  const args = [];
package/dist/design.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // library main.
9
9
  import { readFile } from "node:fs/promises";
10
10
  import { CliError, devRequest } from "./core.js";
11
- import { conformanceLines, figmaConformance, figmaFileKey } from "./figma.js";
11
+ import { conformanceLines, figmaConformance, figmaFileKey, submitMode } from "./figma.js";
12
12
  import { checkConformance, neededMains, VERSION as CHECK_VERSION } from "./vendor/ds-conformance.mjs";
13
13
 
14
14
  const FIGMA_API = "https://api.figma.com/v1";
@@ -159,7 +159,7 @@ export async function lastPublish(figmaGet, fileKey) {
159
159
  /**
160
160
  * The main of each instance, keyed by the instance's componentId (this file's
161
161
  * id for it), resolved as Dev's gate resolves it (Dev issue 92, conformance
162
- * 1.0.3), so both give one verdict:
162
+ * 1.0.4), so both give one verdict:
163
163
  * - a local component (remote: false) is read from this file;
164
164
  * - a remote one is found in the DS file's published listing, else by Figma's
165
165
  * lookup of its key, and is read only from a library Dev has registered
@@ -273,8 +273,10 @@ const nodeId = (x) => (/^\d+-\d+$/.test(x) ? x.replace("-", ":") : x);
273
273
  * finding fails; stale overrides (a value equal to the main's) are listed but
274
274
  * never fail (DEC-0035).
275
275
  */
276
- export async function designCheck(target, flags = {}, { request = devRequest, env = process.env, figmaGet, read } = {}) {
277
- if (flags["change-set"] !== undefined) return designCheckChangeSet(target, flags, { request, ...(read ? { read } : {}) });
276
+ export async function designCheck(target, flags = {}, { request = devRequest, send, progress, env = process.env, figmaGet, read } = {}) {
277
+ submitMode(flags);
278
+ if (flags["change-set"] !== undefined)
279
+ return designCheckChangeSet(target, flags, { request, ...(send ? { send } : {}), ...(progress ? { progress } : {}), ...(read ? { read } : {}) });
278
280
  // A --nodes that names nothing must not widen to every page.
279
281
  if (flags.nodes !== undefined && ([flags.nodes].flat().includes(true) || !list(flags.nodes).length))
280
282
  throw new CliError("USAGE", "--nodes needs node ids, e.g. --nodes 12:34,12:56", 2);
@@ -345,6 +347,8 @@ export async function designCheck(target, flags = {}, { request = devRequest, en
345
347
  // Same value through a different variable: the file is behind a library
346
348
  // republish. Accept the update in Figma (MW6-17); never a failure.
347
349
  libraryUpdatePending: result.libraryUpdatePending ?? [],
350
+ // What the app-UI gate leaves out (email), with its node count (Dev issue 116).
351
+ outOfScope: result.outOfScope ?? [], outOfScopeNodes: result.outOfScopeNodes ?? 0,
348
352
  warnings,
349
353
  };
350
354
  return { payload: { data }, exitCode: data.passed ? 0 : 1 };
@@ -367,8 +371,9 @@ export function designCheckText({ data }) {
367
371
  * plus the submit-time guards against the set's items: IMAGE_NOT_TRUECOLOR
368
372
  * refuses; STALE_FIGMA_VERSION is a warning. Changes no state.
369
373
  */
370
- export async function designCheckChangeSet(target, flags, { request = devRequest, read = (f) => readFile(f, "utf8") } = {}) {
374
+ export async function designCheckChangeSet(target, flags, { request = devRequest, send, progress, read = (f) => readFile(f, "utf8") } = {}) {
371
375
  const id = flags["change-set"];
376
+ const sync = submitMode(flags);
372
377
  if (id === true || !id) throw new CliError("USAGE", "--change-set needs a change set id, e.g. --change-set CS-123", 2);
373
378
  if (target !== undefined) throw new CliError("USAGE", "design check --change-set CS takes no file: Dev checks the set's own items", 2);
374
379
  if (flags["figma-version"] === true) throw new CliError("USAGE", "--figma-version needs a version id", 2);
@@ -387,17 +392,18 @@ export async function designCheckChangeSet(target, flags, { request = devRequest
387
392
  if (flags["figma-version"]) body = { ...body, figmaVersion: String(flags["figma-version"]) };
388
393
  let p;
389
394
  try {
390
- p = await figmaConformance(id, body, { request });
395
+ p = await figmaConformance(id, body, { request, sync, ...(send ? { send } : {}), ...(progress ? { progress } : {}) });
391
396
  } catch (error) {
392
397
  // A cold run reads every root and main from Figma and can outlast the
393
398
  // edge's gateway (504, an HTML page). Dev keeps the mains it read for 5
394
399
  // minutes, so the same run again usually answers.
395
- if (error?.code !== "HTTP_504") throw error;
396
- throw new CliError("DEV_TIMEOUT", `Dev's dry run of ${id} ran past the edge's gateway timeout while reading Figma; run it again (Dev keeps the library mains it read for 5 minutes)`, 1);
400
+ if (error?.code !== "HTTP_504" && error?.code !== "HTTP_502") throw error;
401
+ throw new CliError("DEV_TIMEOUT", `Dev's dry run of ${id} ran past the edge's gateway timeout while reading Figma; run it again${sync ? " without --sync, so Dev runs it as a job" : ""} (Dev keeps the library mains it read for 5 minutes)`, 1);
397
402
  }
398
403
  const r = p.data ?? {};
399
404
  const data = { changeSetId: id, checkVersion: r.version, passed: Boolean(r.passed), checked: r.checked, findings: r.findings ?? [],
400
- staleOverrides: r.staleOverrides ?? [], libraryUpdatePending: r.libraryUpdatePending ?? [], warnings: r.warnings ?? [], staleVersion: r.staleVersion ?? null };
405
+ staleOverrides: r.staleOverrides ?? [], libraryUpdatePending: r.libraryUpdatePending ?? [],
406
+ outOfScope: r.outOfScope ?? [], outOfScopeNodes: r.outOfScopeNodes ?? 0, warnings: r.warnings ?? [], staleVersion: r.staleVersion ?? null };
401
407
  return { payload: { data }, exitCode: data.passed ? 0 : 1, human: designCheckChangeSetText };
402
408
  }
403
409
 
package/dist/doctor.js CHANGED
@@ -48,7 +48,7 @@ export function grantReason(grant = {}) {
48
48
  return "unspecified";
49
49
  }
50
50
 
51
- async function readConfigFile(path) {
51
+ export async function readConfigFile(path) {
52
52
  let raw;
53
53
  try {
54
54
  raw = await readFile(path, "utf8");
@@ -56,6 +56,8 @@ async function readConfigFile(path) {
56
56
  if (error?.code === "ENOENT") return null;
57
57
  return { unreadable: error?.code ?? String(error) };
58
58
  }
59
+ // An empty file is a path made ready for a new agent (`agent register` adopts the machine's sign-in).
60
+ if (!raw.trim()) return null;
59
61
  try { return JSON.parse(raw); } catch { return {}; }
60
62
  }
61
63
 
@@ -106,8 +108,9 @@ export async function doctorCommand(deps = {}) {
106
108
  try {
107
109
  await (deps.loadRefreshToken ?? loadRefreshToken)(config.clientId);
108
110
  stored = true;
109
- } catch {
110
- checks.push(missing("signed_in", "no stored sign-in credential for this app in the keychain", loginFix));
111
+ } catch (error) {
112
+ if (error?.code === "KEYCHAIN_UNAVAILABLE") checks.push(missing("signed_in", "the keychain could not be read (locked or access denied)", "unlock the login keychain, then rerun"));
113
+ else checks.push(missing("signed_in", "no stored sign-in credential for this app in the keychain", loginFix));
111
114
  }
112
115
  if (stored) {
113
116
  try {