@nodii/dev 0.6.20 → 0.6.22

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
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";
@@ -79,11 +77,14 @@ const HELP = `nodii-dev ${VERSION}
79
77
 
80
78
  Usage: nodii-dev <command> [options] [--json]
81
79
 
82
- 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)
83
83
  doctor [--figma-file KEY] [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots
84
84
  # and, given a key (or NODII_DEV_FIGMA_FILE), that Figma file's registration
85
85
  agent register --name NAME | agent heartbeat
86
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
87
88
  issues transition ISSUE --to STATUS [--reason TEXT] [--dev-check REF]
88
89
  # blocked and wont_do need --reason; done needs --dev-check
89
90
  inbox list|show
@@ -134,7 +135,8 @@ Usage: nodii-dev <command> [options] [--json]
134
135
  rules cap TOKENS # operator: set the core token cap (default 3000)
135
136
 
136
137
  lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
137
- 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
138
140
  lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
139
141
  lane heartbeat|review|land|verify|prioritize|deprioritize|stop <key>
140
142
  lane non-issue <key> --reason TEXT --evidence TEXT --reroute KEY[,KEY]|none
@@ -199,18 +201,22 @@ async function login(flags, json) {
199
201
  "nodii-dev talks only to Dev, through the platform edge, signed in as the platform tenant's OAuth app. Run `nodii-dev login`.",
200
202
  2,
201
203
  );
202
- const previous = await readConfig(false);
203
- const result = await deviceLogin({
204
- clientId: flags["client-id"] ?? process.env.NODII_DEV_OAUTH_CLIENT_ID ?? previous?.clientId ?? DEFAULT_CLIENT_ID,
205
- authUrl: flags["auth-url"] ?? process.env.NODII_DEV_AUTH_URL ?? DEFAULT_AUTH_URL,
206
- edgeUrl: flags["edge-url"] ?? process.env.NODII_DEV_EDGE_URL ?? DEFAULT_EDGE_URL,
207
- 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,
208
211
  noBrowser: Boolean(flags["no-browser"]),
209
212
  scope: flags.scope,
210
213
  output: json ? console.error : console.log,
211
- previous,
212
214
  });
213
- 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,
214
220
  result.agentId
215
221
  ? `OAuth login complete; refresh credential stored in ${result.storage}. Kept agent registration ${result.agentId}.`
216
222
  : `OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
@@ -237,6 +243,18 @@ async function issues(action, args, f, json) {
237
243
  if (action === "show")
238
244
  return emit(await devRequest("GET", `/api/v1/issues/${encode(ref)}`), json);
239
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;
240
258
  if (action === "create" || action === "update") {
241
259
  const bodyMd = await bodyFlag(f);
242
260
  const payload = {
@@ -257,11 +275,23 @@ async function issues(action, args, f, json) {
257
275
  ].filter(([, v]) => v !== undefined),
258
276
  ),
259
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.`);
260
281
  const result = await devRequest(
261
282
  action === "create" ? "POST" : "PATCH",
262
283
  action === "create" ? "/api/v1/issues" : `/api/v1/issues/${encode(ref)}`,
263
284
  payload,
264
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
+ }
265
295
  return emit(
266
296
  result,
267
297
  json,
@@ -296,6 +326,8 @@ async function issues(action, args, f, json) {
296
326
  throw new CliError("USAGE", `Unknown issues action: ${action}`, 2);
297
327
  }
298
328
 
329
+ // commentOnIssue's body limit.
330
+ const COMMENT_MAX = 10_000;
299
331
  const ISSUE_STATUSES = ["todo", "in_progress", "in_review", "blocked", "done", "wont_do"];
300
332
  // A blocked reason names who or what clears it: "cleared by", "unblocks", "waiting on", "until".
301
333
  const CLEARED_BY = /\b(clear(s|ed)?|unblock(s|ed)?|wait(s|ing)? (on|for)|until)\b/i;
@@ -391,9 +423,19 @@ export async function main(argv = process.argv.slice(2)) {
391
423
  }
392
424
  if (command === "login") return login(f, json);
393
425
  if (command === "logout") {
426
+ refuseFromLane("logout");
394
427
  const c = await readConfig(false);
395
428
  if (c) {
396
- 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();
397
439
  } else await deleteCredentials();
398
440
  return emit({ loggedOut: true }, json, "Logged out.");
399
441
  }
@@ -485,13 +527,15 @@ export async function main(argv = process.argv.slice(2)) {
485
527
  if (command === "agent") {
486
528
  if (["lane-limit", "grant", "revoke"].includes(action))
487
529
  return emitCommand(await agentWorkflowCommand(action, rest, f));
488
- 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();
489
533
  if (action === "register") {
490
534
  // POST /api/v1/agents is the registration route the edge exposes; Dev
491
535
  // binds the agent to the OAuth person, app and installation itself.
492
536
  const result = await devRequest("POST", "/api/v1/agents", {
493
537
  sessionId: f.session ?? crypto.randomUUID(),
494
- name: requireFlag(f, "name"),
538
+ name,
495
539
  });
496
540
  await writeConfig({ ...c, agentId: result.data.id });
497
541
  // Dev ignores a self-claimed service: staff bind agents to services
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.20";
7
+ export const VERSION = "0.6.22";
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);
@@ -193,7 +199,7 @@ export async function checkLogin() {
193
199
  export const encode = (value) => encodeURIComponent(value);
194
200
  // Switches never take the next token as their value, so
195
201
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
196
- const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync"]);
202
+ const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force"]);
197
203
  export function parseArgs(argv) {
198
204
  const flags = {};
199
205
  const args = [];
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 {
package/dist/lanes.js CHANGED
@@ -4,6 +4,8 @@ import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./
4
4
 
5
5
  const execFileAsync = promisify(execFile);
6
6
  const via = { via: "nodii-dev" };
7
+ // heartbeatLane's note limit.
8
+ const BLOCK_NOTE_MAX = 500;
7
9
 
8
10
  const need = (value, usage) => {
9
11
  if (!value) throw new CliError("USAGE", usage, 2);
@@ -131,11 +133,31 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
131
133
  if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, or --block.", 2);
132
134
  const side = requireFlag(flags, "side");
133
135
  const stepRef = need(flags[selected[0]] === true ? args[1] : flags[selected[0]], "lane step needs <key> --side <side> --start|--done|--block <step>");
136
+ const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
137
+ if (flags.reason !== undefined && (selected[0] !== "block" || !reason)) throw new CliError("USAGE", "--reason TEXT goes with --block.", 2);
134
138
  const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`)).data;
135
139
  const step = lane.steps.find((x) => x.side === side && (x.id === stepRef || x.stepKey === stepRef));
136
140
  if (!step) throw new CliError("STEP_NOT_FOUND", `No ${side} step ${stepRef} on ${ref}.`, 1);
141
+ // Dev's blockLaneStep stores no reason and refuses a step that is already
142
+ // blocked (INVALID_STEP_STATE), so the reason is a lane heartbeat note
143
+ // until Dev issue 125 lets the step carry it.
144
+ const note = reason && `blocked ${side}/${step.stepKey}: ${reason}`;
145
+ if (note.length > BLOCK_NOTE_MAX) throw new CliError("USAGE", `--reason is too long: Dev keeps ${BLOCK_NOTE_MAX} characters of a lane note, and this one is ${note.length}.`, 2);
146
+ const recordReason = () => request("POST", `/api/v1/lanes/${encode(id)}/heartbeat`, { note, ...via });
147
+ if (selected[0] === "block" && step.state === "blocked") {
148
+ if (!reason) throw new CliError("USAGE", `${side}/${step.stepKey} on ${ref} is already blocked; pass --reason TEXT to record its new reason.`, 2);
149
+ await recordReason();
150
+ return { payload: { data: step, reason }, human: `${ref}: ${side}/${step.stepKey} is still blocked; new reason recorded.` };
151
+ }
137
152
  const payload = await request("POST", `/api/v1/lanes/${encode(id)}/steps/${encode(step.id)}/${selected[0]}`, via);
138
- return { payload, human: `${ref}: ${side}/${step.stepKey} ${selected[0]}` };
153
+ if (reason) {
154
+ try {
155
+ await recordReason();
156
+ } catch (error) {
157
+ throw new CliError(error?.code ?? "LANE_NOTE_FAILED", `${side}/${step.stepKey} on ${ref} is blocked, but its reason was not recorded (${error?.message ?? error}). Run the same --block --reason again to record it.`, 1);
158
+ }
159
+ }
160
+ return { payload: reason ? { ...payload, reason } : payload, human: `${ref}: ${side}/${step.stepKey} ${selected[0]}${reason ? `: ${reason}` : ""}` };
139
161
  }
140
162
  if (action === "evidence") {
141
163
  const body = evidence(flags);
package/dist/oauth.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { mkdir, open, readFile, rm, stat, utimes } from "node:fs/promises";
3
+ import { mkdir, open, readdir, readFile, rm, stat, utimes } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
- import { dirname, join } from "node:path";
6
- import { CliError, configPath, readConfig, writeConfig } from "./core.js";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { CliError, authProvider, configPath, isOAuthConfig, readConfig, writeConfig } from "./core.js";
7
7
 
8
8
  // nodii-auth is reached through the platform edge (/oauth/device/authorize and
9
9
  // /oauth/token are public there), so auth and API calls share one host.
@@ -40,7 +40,7 @@ function run(command, args, { input } = {}) {
40
40
  child.stdout.on("data", (chunk) => { stdout += chunk; });
41
41
  child.stderr.on("data", () => {}); // Credential helpers can echo secrets in diagnostics.
42
42
  child.on("error", reject);
43
- child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(`${command} failed`)));
43
+ child.on("close", (code) => code === 0 ? resolve(stdout) : reject(Object.assign(new Error(`${command} failed`), { exitCode: code })));
44
44
  if (input) child.stdin.end(input);
45
45
  });
46
46
  }
@@ -157,11 +157,20 @@ export async function storeRefreshToken(clientId, token, os = platform(), keycha
157
157
  return stored;
158
158
  }
159
159
 
160
+ const SECURITY_NOT_FOUND = 44;
161
+
160
162
  export async function loadRefreshToken(clientId, os = platform(), keychain = {}) {
161
163
  try {
162
164
  const token = await keychainLoad(account(clientId), os, keychain);
163
165
  if (token) return token;
164
- } catch {}
166
+ } catch (error) {
167
+ // `security` exits 44 for an item that is not there. Any other exit is a
168
+ // keychain that could not be read (locked, access denied): not a missing
169
+ // sign-in, so `login` must not replace it. `secret-tool lookup` exits 1
170
+ // for both, so on Linux the two cannot be told apart.
171
+ if (os === "darwin" && Number.isInteger(error?.exitCode) && error.exitCode !== SECURITY_NOT_FOUND)
172
+ throw new CliError("KEYCHAIN_UNAVAILABLE", "The keychain could not be read (locked or access denied); the saved sign-in was kept. Unlock it and retry.", 1);
173
+ }
165
174
  throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth credentials are unavailable; run `nodii-dev login` again.", 2);
166
175
  }
167
176
 
@@ -271,22 +280,150 @@ export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUr
271
280
  return stored;
272
281
  }, { clientId });
273
282
  const claims = decodeClaims(token.access_token);
274
- const person = claims.sub ?? null;
275
- const installationId = claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null;
283
+ const { person, installationId } = signedInAs(claims);
276
284
  const agentId = keptAgent(previous, { clientId, person, installationId, edgeUrl });
277
285
  await writeConfig({ authProvider: "oauth", authUrl, edgeUrl, clientId, tenantHint, agentId, person, installationId });
278
286
  return { storage, claims, agentId };
279
287
  }
280
288
 
289
+ /** Who an access token's claims sign in, and through which installation. */
290
+ export const signedInAs = (claims) => ({ person: claims.sub ?? null, installationId: claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null });
291
+
281
292
  export function decodeClaims(token) {
282
293
  try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); } catch { return {}; }
283
294
  }
284
295
 
296
+ // ONE sign-in per machine (Dev issue 110). The refresh credential is kept in
297
+ // the keychain under the OAuth client, not beside a config file, so every
298
+ // config path on the machine shares it, an empty or new one included. A config
299
+ // adds only routing (auth and edge URLs, tenant) and the agent.
300
+ export const configHome = () => join(homedir(), ".config/nodii-dev");
301
+
302
+ /** The OAuth configs in the config home and its agents/ other than the one in use, newest first. */
303
+ export async function machineConfigs({ home = configHome(), current = configPath() } = {}) {
304
+ const agents = join(home, "agents");
305
+ const names = await readdir(agents).catch(() => []);
306
+ const files = new Set([resolve(home, "config.json"), ...names.filter((n) => n.endsWith(".json")).map((n) => resolve(agents, n))]);
307
+ files.delete(resolve(current));
308
+ const found = await Promise.all([...files].map(async (file) => {
309
+ try {
310
+ const config = JSON.parse(await readFile(file, "utf8"));
311
+ return isOAuthConfig(config) && config.clientId ? { config, mtime: (await stat(file)).mtimeMs } : null;
312
+ } catch {
313
+ return null;
314
+ }
315
+ }));
316
+ return found.filter(Boolean).sort((a, b) => b.mtime - a.mtime).map((x) => x.config);
317
+ }
318
+
319
+ /**
320
+ * The machine's sign-in for an OAuth client. `valid` when the shared access
321
+ * token is live or the keychain refresh credential refreshes; `dead` when
322
+ * nodii-auth refused the credential (invalid_grant); `refused` when it refused
323
+ * the refresh otherwise; `none` when the keychain holds none. Any other failure
324
+ * (auth unreachable, a refresh busy, a keychain that cannot be read) throws: it
325
+ * says nothing about the sign-in, so nothing may replace it. `check: false`
326
+ * only reads who the machine's configs name (state `unchecked`), without a
327
+ * refresh. `config` is the routing to write, never with an agent.
328
+ */
329
+ export async function machineSignIn({ current = null, clientId, authUrl, edgeUrl, tenantHint, check = true, provider, configs = machineConfigs } = {}) {
330
+ // The client named, else the config's, else the machine's newest, else nodii-dev's own.
331
+ const wanted = clientId ?? (isOAuthConfig(current) ? current.clientId : undefined);
332
+ const known = isOAuthConfig(current) && (!wanted || current.clientId === wanted) ? current
333
+ : (await configs()).find((c) => !wanted || c.clientId === wanted) ?? null;
334
+ const id = wanted ?? known?.clientId ?? DEFAULT_CLIENT_ID;
335
+ // The credential is checked against the host it came from (a config's), so
336
+ // a refresh is never sent to another auth server, whose invalid_grant would
337
+ // clear the machine's one credential. A new device flow uses the current
338
+ // defaults instead (see login).
339
+ const config = { authProvider: "oauth", authUrl: authUrl ?? known?.authUrl ?? DEFAULT_AUTH_URL, edgeUrl: edgeUrl ?? known?.edgeUrl ?? DEFAULT_EDGE_URL,
340
+ clientId: id, tenantHint: tenantHint ?? known?.tenantHint ?? PLATFORM_TENANT };
341
+ const person = known?.person ?? null;
342
+ if (!check) return { state: "unchecked", person, config };
343
+ let token;
344
+ try {
345
+ token = await (provider ?? (await authProvider())).accessToken(config);
346
+ } catch (error) {
347
+ // nodii-auth refusing the refresh outright (a JSON 4xx other than
348
+ // invalid_grant, e.g. invalid_client) cannot be fixed by retrying either.
349
+ if (error?.code === "OAUTH_REFRESH_FAILED") return { state: "refused", person, refusal: error.details?.refusal ?? "refused", config };
350
+ if (error?.code !== "OAUTH_LOGIN_REQUIRED") throw error;
351
+ return error.details?.grant ? { state: "dead", person, grant: error.details.grant, config } : { state: "none", person, config };
352
+ }
353
+ const claims = signedInAs(decodeClaims(token));
354
+ const signedIn = claims.person ?? person;
355
+ const installationId = claims.installationId ?? (known?.person === signedIn ? known.installationId ?? null : null);
356
+ return { state: "valid", person: signedIn, config: { ...config, person: signedIn, installationId } };
357
+ }
358
+
359
+ const sameSignIn = (a, b) => isOAuthConfig(a) && ["authUrl", "edgeUrl", "clientId", "tenantHint", "person", "installationId"].every((k) => (a[k] ?? null) === (b[k] ?? null));
360
+
361
+ /**
362
+ * `login --force` and `logout` replace or end the machine's one sign-in, for
363
+ * every lane at once, so a lane's config (named `lane-*.json`, as the
364
+ * coordinator names them) refuses both.
365
+ */
366
+ export function refuseFromLane(command, path = configPath()) {
367
+ if (basename(path).startsWith("lane-"))
368
+ throw new CliError("USAGE", `${command} would end the machine's one sign-in for every lane; a lane never runs it. Ask the coordinator.`, 2);
369
+ }
370
+
371
+ /**
372
+ * `nodii-dev login`. A machine that already holds a valid sign-in says so and
373
+ * starts no device flow, writing its routing into an empty config path (the
374
+ * agent is kept only if it is bound to that same sign-in). A dead sign-in is
375
+ * named, then replaced by a device flow. `force` starts a device flow whatever
376
+ * the machine holds and names whom it replaces; a lane's config (named
377
+ * `lane-*.json`, as the coordinator names them) refuses it.
378
+ */
379
+ export async function login({ force = false, previous = null, clientId, authUrl, edgeUrl, tenantHint, noBrowser, scope, output = console.log,
380
+ machine = machineSignIn, device = deviceLogin, write = writeConfig, path = configPath() } = {}) {
381
+ if (force) refuseFromLane("login --force", path);
382
+ const found = await machine({ current: previous, clientId, authUrl, edgeUrl, tenantHint, check: !force });
383
+ if (found.state === "valid") {
384
+ if (scope !== undefined)
385
+ throw new CliError("USAGE", `You're already signed in as ${found.person} on this machine. A different --scope replaces that one sign-in for every config path: ${basename(path).startsWith("lane-") ? "ask the coordinator" : "pass --force with it"}.`, 2);
386
+ if (sameSignIn(previous, found.config)) return { alreadySignedIn: true, person: found.person, agentId: previous.agentId ?? null };
387
+ const agentId = keptAgent(previous, found.config);
388
+ await write({ ...found.config, agentId });
389
+ return { alreadySignedIn: true, person: found.person, agentId };
390
+ }
391
+ // A lane never starts a device flow: it would replace every lane's sign-in, and no one watches a lane's terminal.
392
+ refuseFromLane("login (a new device sign-in)", path);
393
+ if (found.state === "dead")
394
+ output(`The saved sign-in${found.person ? ` of ${found.person}` : ""} on this machine is no longer accepted by nodii-auth (${found.grant?.error ?? "invalid_grant"}: expired, revoked or replayed). Starting a new sign-in.`);
395
+ if (found.state === "refused")
396
+ output(`nodii-auth refused to refresh the saved sign-in${found.person ? ` of ${found.person}` : ""} on this machine (${found.refusal}). Starting a new sign-in.`);
397
+ if (force) output(found.person ? `This replaces the sign-in of ${found.person} on this machine, for every config path that uses it.` : "This replaces the machine's sign-in, if it holds one (no config here names a person).");
398
+ // A new sign-in goes to the flags' hosts or the current defaults, as login always did, never an old config's.
399
+ const result = await device({ clientId: found.config.clientId, authUrl: authUrl ?? DEFAULT_AUTH_URL, edgeUrl: edgeUrl ?? DEFAULT_EDGE_URL, tenantHint: tenantHint ?? PLATFORM_TENANT,
400
+ noBrowser, scope, output, previous });
401
+ const dead = { dead: found.grant?.error ?? "invalid_grant", refused: found.refusal }[found.state];
402
+ return { ...result, alreadySignedIn: false, person: result.claims?.sub ?? null, ...(force ? { replaced: found.person ?? null } : {}),
403
+ ...(dead ? { replacedDeadSignIn: { person: found.person ?? null, error: dead } } : {}) };
404
+ }
405
+
406
+ /**
407
+ * The config for a command that needs a sign-in on a config path that has
408
+ * none yet (`agent register` on a fresh lane path): the machine's valid
409
+ * sign-in, written there without an agent.
410
+ */
411
+ export async function adoptMachineSignIn({ machine = machineSignIn, write = writeConfig, env = process.env } = {}) {
412
+ const found = await machine({ clientId: env.NODII_DEV_OAUTH_CLIENT_ID, authUrl: env.NODII_DEV_AUTH_URL, edgeUrl: env.NODII_DEV_EDGE_URL, tenantHint: env.NODII_DEV_PLATFORM_TENANT_ID });
413
+ if (found.state !== "valid")
414
+ throw new CliError("NOT_LOGGED_IN", {
415
+ dead: "This machine's sign-in is no longer accepted by nodii-auth (invalid_grant). Run `nodii-dev login` first.",
416
+ refused: `nodii-auth refused to refresh this machine's sign-in (${found.refusal}). Run \`nodii-dev login\` first.`,
417
+ }[found.state] ?? "No sign-in on this machine. Run `nodii-dev login` first.", 2);
418
+ await write(found.config);
419
+ return found.config;
420
+ }
421
+
285
422
  // ONE lock per OAuth client per user, beside the Keychain item it guards. It
286
423
  // used to live next to the config file, so two processes with different
287
424
  // NODII_DEV_CONFIG_PATH values shared the credential but not the lock.
288
425
  export function refreshLockPath(clientId = DEFAULT_CLIENT_ID) {
289
- return join(homedir(), ".config/nodii-dev", `oauth-refresh-${String(clientId).replace(/[^A-Za-z0-9_.-]/g, "_")}.lock`);
426
+ return join(configHome(), `oauth-refresh-${String(clientId).replace(/[^A-Za-z0-9_.-]/g, "_")}.lock`);
290
427
  }
291
428
 
292
429
  const LOCK_STALE_MS = 30_000;
@@ -510,10 +647,11 @@ export class OAuthAuthProvider {
510
647
  this.#expiresAt = cached.expiresAt;
511
648
  return true;
512
649
  }
513
- async accessToken() {
650
+ /** `given`: the routing to use instead of the config file (a sign-in found on the machine). */
651
+ async accessToken(given) {
514
652
  if (this.#accessToken && Date.now() < this.#expiresAt - 30_000) return this.#accessToken;
515
653
  this.#refreshPromise ??= (async () => {
516
- const config = await readConfig();
654
+ const config = given ?? await readConfig();
517
655
  // Another process may have refreshed already: its access token is shared.
518
656
  if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
519
657
  return this.lock(async ({ held } = {}) => {
@@ -599,7 +737,7 @@ export class OAuthAuthProvider {
599
737
  if (kind === "retry")
600
738
  throw new CliError("OAUTH_REFRESH_UNAVAILABLE", `Could not refresh the sign-in (${describe(result)}) after ${attempts} attempt${attempts === 1 ? "" : "s"}; the saved credential was kept. Retry the command shortly.`, 1);
601
739
  if (kind === "refused")
602
- throw new CliError("OAUTH_REFRESH_FAILED", `nodii-auth refused the refresh (${describe(result)}); the saved credential was kept.`, 1);
740
+ throw new CliError("OAUTH_REFRESH_FAILED", `nodii-auth refused the refresh (${describe(result)}); the saved credential was kept.`, 1, { refusal: describe(result) });
603
741
  const body = result.body;
604
742
  for (let attempt = 1; ; attempt++) {
605
743
  try { await this.storeToken(config.clientId, body.refresh_token); break; } catch (error) {
@@ -30,7 +30,7 @@
30
30
  // it until it returns [] (each round can reveal instances inside slot
31
31
  // content).
32
32
 
33
- export const VERSION = "1.0.4";
33
+ export const VERSION = "1.0.9";
34
34
 
35
35
  /** @typedef {{ nodeId: string, check: string, detail: string }} Finding */
36
36
  /**
@@ -135,22 +135,18 @@ const NOT_A_TABLE = /\blist page\b/i;
135
135
  const CUSTOMER_HEADER =
136
136
  /^(customer|client|contact|lead|applicant|candidate)s?( name)?$/i;
137
137
  const EXTERNAL_PLANE = /customer portal|partner room|external/i;
138
- // The viewer's own record (R5): the App Frame sidebar footer, the user menu
139
- // and the profile. Not any footer: a dialog or table footer can list others.
140
- const SELF_CONTAINER =
141
- /sidebar ?footer|user button|user menu|account menu|profile|\b(my|own) (account|tokens?|security|sessions?|settings|notification settings|records?)\b/i;
142
- // A frame the screen marks as the viewer's own: "[self]", "(self)", a
143
- // trailing "· self", or "(you)".
144
- const SELF_MARK = /\[self\]|\(self\)|·\s*self\s*$|\(you\)/i;
145
- // Where the screens show who the viewer is: the App Frame footer, the user
146
- // and account menus, and frames marked self or named my/own.
147
- const VIEWER_PLACE =
148
- /sidebar ?footer|user button|user menu|account menu|\b(my|own) (profile|account|tokens?|security|sessions?|settings)\b/i;
149
- const SELF_TEXT = /\(you\)|^you$/i;
150
- const SIGNED_IN = /^(signed in( as\b.*)?|not you\?.*)$/i;
151
- // The signed-in account card (Account Context, kind=Account) shows the
152
- // viewer's own email.
153
- const SELF_COMPONENT = /^account context$/i;
138
+ const APP_FRAME_COMPONENT_SET_KEY = "e725461fd9786a3f1527ddee0ee764e9dcead46b";
139
+ const PERSON_NAME_COMPONENT_SET_KEY =
140
+ "08370f38bcbbcf88eaf3a6923dc91b5de5f38ea0";
141
+ const TABLE_COMPONENT_SET_KEY = "7ccc1dbb6ebd149fb9aaf42b9c638f5ce7169638";
142
+ const TABLE_CELL_COMPONENT_SET_KEY = "528898c19702ed4b169d023de30380e05169dfa5";
143
+ const ITEM_COMPONENT_SET_KEY = "46abc8cb0d57c22a365da94a03cfd72a0b089b87";
144
+ const SELF_COMPONENT_SET_KEYS = new Set([
145
+ "af8df2e85ba4d53b26b28ffa69f264758a50fe66", // Account Context
146
+ "470b2b5fd91dcae681d3d38c0297c71df3bb998f", // Account Menu
147
+ "8499452873bccfe5d2f29b5322fc1ca18cb103cc", // Profile Name Card
148
+ "6c1e074dcf69aee388e0300584da09735e314cc3", // Delete Account Card
149
+ ]);
154
150
  const NOT_A_PERSON = /^(you|the team)$/i;
155
151
  const MASKED = /[*•●]/;
156
152
  const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
@@ -164,6 +160,9 @@ const PHONE = /(?<![\w.+\-/])\+?\(?\d[\d ().-]{7,}\d(?![\w\-/])/g;
164
160
  const LOCAL_PHONE = /^(\(\d{3,5}\)[ .-]?|\d{3,5}[ .-])\d{3,5}([ .-]\d{2,5})?$/;
165
161
  const DATE = /^(\d{4}-\d{2}-\d{2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4})/;
166
162
  const ROLE_ANNOTATION = /\[role:([a-z0-9._-]+)\]/gi;
163
+ // Published Dialog component-set key. A node id would break when the set is
164
+ // moved; the key is the stable reference shared across library consumers.
165
+ const DIALOG_COMPONENT_SET_KEY = "55d4155c4bce0136e9a3740ecc7174f9bd01727c";
167
166
  // otherPersonRevealed is about people (Dev issue 116): a value is judged
168
167
  // by the field it fills. A tenant's business contact (support phone or
169
168
  // email, caller or sender id, business address) is not personal.
@@ -242,6 +241,13 @@ const styleKeys = (node, styles) => {
242
241
  function fieldValue(node, field, styles) {
243
242
  if (STYLE_FIELD.test(field) || field === "styles")
244
243
  return styleKeys(node, styles);
244
+ // Figma names the override `fontName`; REST exposes its two values in the
245
+ // text node's TypeStyle rather than as a direct FontName property.
246
+ if (field === "fontName")
247
+ return {
248
+ family: node?.style?.fontFamily,
249
+ style: node?.style?.fontStyle,
250
+ };
245
251
  if (TEXT_STYLE_FIELDS.has(field) || field.startsWith("lineHeight"))
246
252
  return node?.style?.[field];
247
253
  // An image fill's picture is content (DEC-0048): only its scale mode,
@@ -420,7 +426,13 @@ function outOfScopeRoot(n, parent, componentLabel, memo) {
420
426
  if (hit) return hit;
421
427
  let kind = /** @type {"kit" | "other" | "none"} */ ("none");
422
428
  if (emailKitPart(x, parent, componentLabel)) kind = "kit";
423
- else if (x.type === "INSTANCE") kind = "other";
429
+ else if (
430
+ x.type === "INSTANCE" ||
431
+ x.type === "TEXT" ||
432
+ visiblePaints(x.fills) ||
433
+ visiblePaints(x.strokes)
434
+ )
435
+ kind = "other";
424
436
  else
425
437
  for (const c of x.children ?? []) {
426
438
  const k = placed(c);
@@ -435,7 +447,16 @@ function outOfScopeRoot(n, parent, componentLabel, memo) {
435
447
  };
436
448
  // An email is named so; an editor's workspace around it, or an app screen
437
449
  // holding a kit part, stays in scope.
438
- return EMAIL_FRAME.test(n.name ?? "") && placed(n) === "kit"
450
+ let content = /** @type {"kit" | "other" | "none"} */ ("none");
451
+ for (const child of n.children ?? []) {
452
+ const kind = placed(child);
453
+ if (kind === "other") {
454
+ content = "other";
455
+ break;
456
+ }
457
+ if (kind === "kit") content = "kit";
458
+ }
459
+ return EMAIL_FRAME.test(n.name ?? "") && content === "kit"
439
460
  ? `email ${n.name}`
440
461
  : null;
441
462
  }
@@ -506,6 +527,59 @@ function matchesRole(a, inst, role, library) {
506
527
  });
507
528
  }
508
529
 
530
+ /** The Dialog instance's Body slot, matched by its stable property name. */
531
+ function dialogBody(a, inst) {
532
+ if (a.setOf(inst)?.key !== DIALOG_COMPONENT_SET_KEY) return null;
533
+ return (
534
+ (inst.children ?? []).find((child) => {
535
+ if (child.type !== "SLOT") return false;
536
+ const property = child.componentPropertyReferences?.slotContentId;
537
+ return /^body(?:#|$)/i.test(property ?? "") || /^body$/i.test(child.name);
538
+ }) ?? null
539
+ );
540
+ }
541
+
542
+ /** A text sublayer whose characters this instance changes through a TEXT prop. */
543
+ function textPropertyOverrideTarget(a, from, node) {
544
+ if (node.type !== "TEXT") return false;
545
+ const property = node.componentPropertyReferences?.characters;
546
+ if (typeof property !== "string") return false;
547
+ for (const ancestor of a.ancestors(node)) {
548
+ if (
549
+ ancestor.type === "INSTANCE" &&
550
+ ancestor.componentProperties?.[property]?.type === "TEXT"
551
+ )
552
+ return true;
553
+ if (ancestor === from) break;
554
+ }
555
+ return false;
556
+ }
557
+
558
+ /**
559
+ * A look field Figma auto-writes when a TEXT component property is set: the
560
+ * typography fields and the text's variable bindings (typography and its fill
561
+ * colour). Only skipped when equal to the main (checked by the caller).
562
+ */
563
+ function autoTextPropertyField(node, field) {
564
+ if (
565
+ field === "fontSize" ||
566
+ field === "fontName" ||
567
+ field === "letterSpacing" ||
568
+ field.startsWith("lineHeight")
569
+ )
570
+ return true;
571
+ if (field !== "boundVariables") return false;
572
+ const keys = Object.keys(node.boundVariables ?? {});
573
+ return (
574
+ keys.length > 0 &&
575
+ keys.every((key) =>
576
+ /^(font(Family|Size|Style|Weight)|letterSpacing|lineHeight|fills)$/.test(
577
+ key,
578
+ ),
579
+ )
580
+ );
581
+ }
582
+
509
583
  /** Characters the screen chose: its own text, or an overridden label. */
510
584
  function screenText(a, n, overridden) {
511
585
  return !a.composed(n) || overridden.has(n.id);
@@ -558,24 +632,15 @@ export function checkConformance({
558
632
  const analyses = Object.values(nodes ?? {})
559
633
  .filter((entry) => entry?.document)
560
634
  .map((entry) => analyse(entry, mains, library));
561
- // The viewer (R5): the email and phone the screens show in the viewer's
562
- // own places (the App Frame footer, a frame marked self). The same value
563
- // anywhere else is still the viewer's own.
635
+ // The viewer (R5): identifiers inside the structural self set. The same
636
+ // value anywhere else is still the viewer's own.
564
637
  const viewer = new Set();
565
638
  for (const a of analyses)
566
639
  for (const n of a.byId.values()) {
567
640
  if (n.type !== "TEXT" || a.out.has(n.id)) continue;
568
641
  const ids = identifiers(textOf(n));
569
642
  if (ids.length === 0) continue;
570
- if (
571
- a
572
- .ancestors(n)
573
- .some(
574
- (x) =>
575
- VIEWER_PLACE.test(x.name ?? "") || SELF_MARK.test(x.name ?? ""),
576
- )
577
- )
578
- for (const id of ids) viewer.add(id);
643
+ if (isSelf(a, n)) for (const id of ids) viewer.add(id);
579
644
  }
580
645
 
581
646
  for (const a of analyses) {
@@ -628,12 +693,15 @@ export function checkConformance({
628
693
  let slotDefault;
629
694
  const differ = [];
630
695
  const equal = [];
696
+ const textPropertyTarget = textPropertyOverrideTarget(a, from, node);
631
697
  for (const field of [...fields].sort()) {
632
698
  if (ALLOWED_FIELDS.has(field)) continue;
633
699
  if (exemptPaint(a, from, node, field)) continue;
634
700
  const mine = fieldValue(node, field, a.styles);
635
701
  const theirs = fieldValue(mainNode, field, mainStyles);
636
702
  if (sameValue(mine, theirs)) {
703
+ if (textPropertyTarget && autoTextPropertyField(node, field))
704
+ continue;
637
705
  equal.push(field);
638
706
  continue;
639
707
  }
@@ -677,6 +745,25 @@ export function checkConformance({
677
745
  for (const r of roles)
678
746
  if (matchesRole(a, n, r, library)) usedRoles.add(r.role);
679
747
 
748
+ // roleMismatch (operator ruling, 30 Oct): a Dialog is for a form,
749
+ // list or other body content. A title, description and actions alone
750
+ // ask a question, which is the Alert Dialog role.
751
+ if (n.type === "INSTANCE") {
752
+ const body = dialogBody(a, n);
753
+ if (
754
+ a.setOf(n)?.key === DIALOG_COMPONENT_SET_KEY &&
755
+ (!body ||
756
+ !a.shown(body) ||
757
+ !(body.children ?? []).some((child) => a.shown(child)))
758
+ )
759
+ push(
760
+ findings,
761
+ n.id,
762
+ "roleMismatch",
763
+ "Dialog Body is empty or hidden; use Alert Dialog",
764
+ );
765
+ }
766
+
680
767
  // tableHeaderFill (DEC-0036): every header cell keeps the library fill.
681
768
  if (n.type === "INSTANCE" && headerCell(a, n)) {
682
769
  const main = mains[n.componentId]?.document;
@@ -1096,42 +1183,94 @@ function handDrawnButton(n) {
1096
1183
  const words = buttonText(n).split(/\s+/).filter(Boolean);
1097
1184
  return words.length > 0 && words.length <= 3;
1098
1185
  }
1186
+ function variantValue(n, property) {
1187
+ for (const [name, value] of Object.entries(n.componentProperties ?? {}))
1188
+ if (
1189
+ name.split("#", 1)[0] === property &&
1190
+ value?.type === "VARIANT" &&
1191
+ typeof value.value === "string"
1192
+ )
1193
+ return value.value;
1194
+ const value = n.variantProperties?.[property];
1195
+ return typeof value === "string" ? value : null;
1196
+ }
1197
+ function selfPersonName(a, n) {
1198
+ return (
1199
+ n.type === "INSTANCE" &&
1200
+ a.setOf(n)?.key === PERSON_NAME_COMPONENT_SET_KEY &&
1201
+ variantValue(n, "state") === "self"
1202
+ );
1203
+ }
1204
+ function selfRow(a, n) {
1205
+ const up = a.ancestors(n);
1206
+ const tableAt = up.findIndex(
1207
+ (x) => x.type === "INSTANCE" && a.setOf(x)?.key === TABLE_COMPONENT_SET_KEY,
1208
+ );
1209
+ if (tableAt !== -1) {
1210
+ if (tableAt < 2) return null;
1211
+ const body = up[tableAt - 1];
1212
+ const row = up[tableAt - 2];
1213
+ const cells = row.children ?? [];
1214
+ if (
1215
+ a.parent.get(row.id)?.id !== body.id ||
1216
+ a.parent.get(body.id)?.id !== up[tableAt].id ||
1217
+ cells.length === 0 ||
1218
+ !cells.every(
1219
+ (cell) =>
1220
+ cell.type === "INSTANCE" &&
1221
+ a.setOf(cell)?.key === TABLE_CELL_COMPONENT_SET_KEY,
1222
+ )
1223
+ )
1224
+ return null;
1225
+ return row;
1226
+ }
1227
+
1228
+ return (
1229
+ up.find(
1230
+ (x) =>
1231
+ x.type === "INSTANCE" && a.setOf(x)?.key === ITEM_COMPONENT_SET_KEY,
1232
+ ) ?? null
1233
+ );
1234
+ }
1099
1235
  function isSelf(a, n) {
1100
1236
  const up = a.ancestors(n);
1237
+ if (up.some((x) => selfPersonName(a, x))) return true;
1101
1238
  if (
1102
1239
  up.some(
1103
- (x) => SELF_CONTAINER.test(x.name ?? "") || SELF_MARK.test(x.name ?? ""),
1240
+ (x) =>
1241
+ x.type === "INSTANCE" && SELF_COMPONENT_SET_KEYS.has(a.setOf(x)?.key),
1104
1242
  )
1105
1243
  )
1106
1244
  return true;
1107
- const accountCard = (x) => {
1108
- const set =
1109
- x.type === "INSTANCE"
1110
- ? a.componentLabel(x)
1111
- : x.type === "COMPONENT"
1112
- ? a.parent.get(x.id)?.name
1113
- : null;
1114
- if (!SELF_COMPONENT.test(set ?? "")) return false;
1115
- const name = x.type === "INSTANCE" ? a.componentOf(x)?.name : x.name;
1116
- const kind = x.componentProperties?.kind?.value;
1117
- return (
1118
- kind === "Account" || /(^|,\s*)kind=account(\s*,|$)/i.test(name ?? "")
1119
- );
1120
- };
1121
- if (up.some(accountCard)) return true;
1122
- const siblings = a.parent.get(n.id)?.children ?? [];
1245
+
1246
+ // INTERIM: identify a Table row from its direct Table-cell children, or a
1247
+ // list row from its Item instance. Switch to the published Table Row key
1248
+ // when DESIGN-SYSTEM-160 ships.
1249
+ const row = selfRow(a, n);
1123
1250
  if (
1124
- [n, ...siblings].some((x) => x.type === "TEXT" && SELF_TEXT.test(textOf(x)))
1251
+ row &&
1252
+ [...indexTree(row).byId.values()].some((x) => selfPersonName(a, x))
1125
1253
  )
1126
1254
  return true;
1127
- // "Signed in" or "Not you?" in the same card: the nearest instance or
1128
- // component holding it, never a whole row or screen.
1129
- const card = up.find((x) => x.type === "INSTANCE" || x.type === "COMPONENT");
1130
- return card
1131
- ? [...indexTree(card).byId.values()].some(
1132
- (x) => x.type === "TEXT" && SIGNED_IN.test(textOf(x)),
1133
- )
1134
- : false;
1255
+
1256
+ // INTERIM: switch this slot rule to the footer-user component key when
1257
+ // DESIGN-SYSTEM-159 ships.
1258
+ const slotAt = up.findIndex((x) => {
1259
+ if (x.type !== "SLOT") return false;
1260
+ // Figma formerly exposed the slot reference as mainComponent; current
1261
+ // REST responses use slotContentId. Accept both so a library republish
1262
+ // cannot turn the viewer's own identifiers into another-person findings.
1263
+ const property =
1264
+ x.componentPropertyReferences?.slotContentId ??
1265
+ x.componentPropertyReferences?.mainComponent;
1266
+ return (
1267
+ typeof property === "string" &&
1268
+ property.split("#", 1)[0] === "sidebarFooter"
1269
+ );
1270
+ });
1271
+ if (slotAt === -1) return false;
1272
+ const owner = up.slice(slotAt + 1).find((x) => x.type === "INSTANCE");
1273
+ return owner ? a.setOf(owner)?.key === APP_FRAME_COMPONENT_SET_KEY : false;
1135
1274
  }
1136
1275
  function revealedByPermission(a, n) {
1137
1276
  return [n, ...a.ancestors(n)].some((x) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.20",
3
+ "version": "0.6.22",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {