@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 +27 -3
- package/dist/cli.js +62 -18
- package/dist/core.js +9 -3
- package/dist/doctor.js +6 -3
- package/dist/lanes.js +23 -1
- package/dist/oauth.js +149 -11
- package/dist/vendor/ds-conformance.mjs +196 -57
- package/package.json +1 -1
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 (
|
|
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
|
-
|
|
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):
|
|
562
|
-
//
|
|
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) =>
|
|
1240
|
+
(x) =>
|
|
1241
|
+
x.type === "INSTANCE" && SELF_COMPONENT_SET_KEYS.has(a.setOf(x)?.key),
|
|
1104
1242
|
)
|
|
1105
1243
|
)
|
|
1106
1244
|
return true;
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1251
|
+
row &&
|
|
1252
|
+
[...indexTree(row).byId.values()].some((x) => selfPersonName(a, x))
|
|
1125
1253
|
)
|
|
1126
1254
|
return true;
|
|
1127
|
-
|
|
1128
|
-
//
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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) => {
|