@nodii/dev 0.6.17 → 0.6.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -3
- package/dist/cli.js +73 -24
- package/dist/core.js +23 -8
- package/dist/design.js +15 -9
- package/dist/doctor.js +6 -3
- package/dist/figma.js +168 -8
- package/dist/lanes.js +23 -1
- package/dist/oauth.js +149 -11
- package/dist/vendor/ds-conformance.mjs +259 -21
- package/package.json +1 -1
package/dist/figma.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { CliError, devAsyncRequest, devRequest, encode } from "./core.js";
|
|
3
4
|
|
|
4
5
|
/** A Figma file key, or the key inside a figma.com file link (a branch link's branch key). */
|
|
5
6
|
export function figmaFileKey(value) {
|
|
@@ -55,7 +56,158 @@ export function resubmitPayload(text, file) {
|
|
|
55
56
|
return body;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
// Async submit, re-submit and dry run (`Prefer: respond-async`): a large set's
|
|
60
|
+
// gate outlasts the edge's 30 s timeout (CS-2470 got a 504 while Dev went on to
|
|
61
|
+
// answer 422), so Dev answers 202 with a job, polled here until it ends. A Dev
|
|
62
|
+
// that ignores the header answers synchronously, which is taken as is.
|
|
63
|
+
const JOB_POLL_MS = [1000, 2000, 3000, 5000];
|
|
64
|
+
// Dev fails a job still running after 20 minutes (SUBMIT_JOB_LOST); stop a little later.
|
|
65
|
+
const JOB_WAIT_MS = 25 * 60_000;
|
|
66
|
+
const JOB_PROGRESS_MS = 15_000;
|
|
67
|
+
const IN_PROGRESS = new Set(["SUBMIT_IN_PROGRESS", "CONFORMANCE_IN_PROGRESS"]);
|
|
68
|
+
const GATEWAY = new Set(["HTTP_502", "HTTP_504"]);
|
|
69
|
+
const TRANSIENT = new Set([...GATEWAY, "HTTP_503", "HTTP_429", "DEV_UNREACHABLE"]);
|
|
70
|
+
const stderrLine = (line) => process.stderr.write(`${line}\n`);
|
|
71
|
+
/** --sync sends the old synchronous call; the default (or --async) runs it as a job in Dev and polls it. */
|
|
72
|
+
export function submitMode(f) {
|
|
73
|
+
if (f.sync && f.async) throw new CliError("USAGE", "pass --sync or --async, not both", 2);
|
|
74
|
+
return Boolean(f.sync);
|
|
75
|
+
}
|
|
76
|
+
const DONE = new Set(["succeeded", "failed"]);
|
|
77
|
+
const jobName = (job) => (job.kind === "conformance" ? "dry run" : job.kind === "resubmit" ? "re-submit" : "submit");
|
|
78
|
+
|
|
79
|
+
export const submitJobPath = (job) => `/api/v1/design/change-sets/${encode(job.changeSetId)}/submit-jobs/${encode(job.id)}`;
|
|
80
|
+
|
|
81
|
+
/** A finished job as the synchronous call would have answered: `succeeded` is its 200 body, `failed` throws its error. */
|
|
82
|
+
export function jobOutcome(job) {
|
|
83
|
+
if (job.state === "succeeded") return job.result ?? {};
|
|
84
|
+
const e = job.error ?? {};
|
|
85
|
+
if (e.code === "SUBMIT_JOB_LOST")
|
|
86
|
+
throw new CliError(e.code, `${e.message ?? "Dev lost this job"} (${jobName(job)} job ${job.id} on ${job.changeSetId}). Run the same command again.`, 1, { job });
|
|
87
|
+
// Never a gateway code: that reads as the edge's gateway on this command's own request.
|
|
88
|
+
throw new CliError(!e.code || GATEWAY.has(e.code) ? "SUBMIT_JOB_FAILED" : e.code, e.message ?? `Dev's ${jobName(job)} job ${job.id} failed with HTTP ${job.httpStatus}`, 1, e.details ?? { httpStatus: job.httpStatus });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Polls a running job (getDesignSubmitJob) with backoff until it ends, printing progress. */
|
|
92
|
+
export async function pollSubmitJob(job, { request = devRequest, sleep = delay, progress = stderrLine, now = Date.now } = {}) {
|
|
93
|
+
const start = now();
|
|
94
|
+
let printed = start;
|
|
95
|
+
let misses = 0;
|
|
96
|
+
for (let n = 0; !DONE.has(job.state); n++) {
|
|
97
|
+
if (now() - start > JOB_WAIT_MS)
|
|
98
|
+
throw new CliError("SUBMIT_JOB_TIMEOUT", `Dev's ${jobName(job)} job ${job.id} is still running after ${Math.round(JOB_WAIT_MS / 60_000)} min; read ${job.changeSetId} with nodii-dev figma changeset show ${job.changeSetId} before running it again.`, 1, { job });
|
|
99
|
+
await sleep(JOB_POLL_MS[Math.min(n, JOB_POLL_MS.length - 1)]);
|
|
100
|
+
try {
|
|
101
|
+
job = { changeSetId: job.changeSetId, ...(await request("GET", submitJobPath(job))).data };
|
|
102
|
+
misses = 0;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error?.code === "SUBMIT_JOB_NOT_FOUND")
|
|
105
|
+
throw new CliError("SUBMIT_JOB_LOST", `Dev no longer knows ${jobName(job)} job ${job.id} on ${job.changeSetId} (it restarted). Read the set with nodii-dev figma changeset show ${job.changeSetId}, then run the same command again.`, 1, { job });
|
|
106
|
+
if (!TRANSIENT.has(error?.code)) throw error;
|
|
107
|
+
// A poll that hit the edge's gateway or a blip retries; the job runs on in Dev.
|
|
108
|
+
if (++misses > 5)
|
|
109
|
+
throw new CliError("SUBMIT_JOB_UNREACHABLE", `Lost touch with Dev (${error.code}) while its ${jobName(job)} job ${job.id} on ${job.changeSetId} ran; the job goes on in Dev. Do not send it again yet: read the set with nodii-dev figma changeset show ${job.changeSetId} in a few minutes.`, 1, { job, cause: error.code });
|
|
110
|
+
progress(`polling ${job.id} again (${error.code})`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!DONE.has(job.state) && now() - printed >= JOB_PROGRESS_MS) {
|
|
114
|
+
printed = now();
|
|
115
|
+
progress(`${job.changeSetId}: ${jobName(job)} job ${job.id} still running (${Math.round((printed - start) / 1000)} s)`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
progress(`${job.changeSetId}: ${jobName(job)} job ${job.id} ${job.state}${job.httpStatus && job.state === "failed" ? ` (HTTP ${job.httpStatus})` : ""} after ${Math.round((now() - start) / 1000)} s`);
|
|
119
|
+
return jobOutcome(job);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sends a change-set call with `Prefer: respond-async` and answers as the
|
|
124
|
+
* synchronous call does. A 409 SUBMIT_IN_PROGRESS or CONFORMANCE_IN_PROGRESS
|
|
125
|
+
* names the job already running for the set: that one is polled instead.
|
|
126
|
+
*/
|
|
127
|
+
export async function changeSetJob(method, path, body, { changeSetId, kind, send = devAsyncRequest, progress = stderrLine, ...poll } = {}) {
|
|
128
|
+
let job;
|
|
129
|
+
let adopted = null;
|
|
130
|
+
try {
|
|
131
|
+
const { status, payload } = await send(method, path, body);
|
|
132
|
+
if (status !== 202) return payload;
|
|
133
|
+
if (!payload?.data?.id)
|
|
134
|
+
throw new CliError("SUBMIT_UNCONFIRMED", `Dev took the ${kind === "conformance" ? "dry run" : kind} on ${changeSetId} as a job (202) but named no job; do not send it again yet: read the set with nodii-dev figma changeset show ${changeSetId}.`, 1, { changeSetId, payload });
|
|
135
|
+
job = { changeSetId, ...payload.data };
|
|
136
|
+
progress(`${job.changeSetId}: Dev is running the ${jobName(job)} as job ${job.id}; polling`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!IN_PROGRESS.has(error?.code) || !error.details?.job?.id) throw error;
|
|
139
|
+
job = { changeSetId, ...error.details.job };
|
|
140
|
+
// A dry run never answers for a submit, nor a submit for a dry run.
|
|
141
|
+
if ((job.kind === "conformance") !== (kind === "conformance")) throw error;
|
|
142
|
+
progress(`a ${jobName(job)} is already running: ${job.id}; polling it`);
|
|
143
|
+
adopted = error;
|
|
144
|
+
}
|
|
145
|
+
if (!adopted) return pollSubmitJob(job, { progress, ...poll });
|
|
146
|
+
// An adopted job ran its own body, so its answer is never this command's: it
|
|
147
|
+
// ends as the 409 it met, with that job's outcome in the details.
|
|
148
|
+
let outcome;
|
|
149
|
+
try {
|
|
150
|
+
outcome = { result: await pollSubmitJob(job, { progress, ...poll }) };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
outcome = { error: { code: error.code, message: error.message, details: error.details } };
|
|
153
|
+
}
|
|
154
|
+
// Polling that stopped (lost the edge, or timed out) leaves the job's end unknown.
|
|
155
|
+
const unknown = !outcome.result && ["SUBMIT_JOB_UNREACHABLE", "SUBMIT_JOB_TIMEOUT"].includes(outcome.error.code);
|
|
156
|
+
const ended = outcome.result ? "succeeded" : unknown ? `has no known outcome yet (${outcome.error.code})` : `failed (${outcome.error.code})`;
|
|
157
|
+
throw new CliError(
|
|
158
|
+
adopted.code,
|
|
159
|
+
`The ${jobName(job)} already running on ${changeSetId} (job ${job.id}) ${ended}; this command's body was not sent. Read the set with nodii-dev figma changeset show ${changeSetId}, then run it again ${unknown ? "only once that job has ended" : "if it still needs to go"}.`,
|
|
160
|
+
1,
|
|
161
|
+
{ job, ...outcome },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A submit lands an editing set in_review, a re-submit an applying one: only that move
|
|
166
|
+
// from the call's own entry state counts, never a set that was in review already.
|
|
167
|
+
const ENTRY = { submit: "editing", resubmit: "applying" };
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A submit or re-submit that met the edge's gateway (502/504) may still have
|
|
171
|
+
* landed in Dev (CS-2468 did): read the set and say which, never send again.
|
|
172
|
+
*/
|
|
173
|
+
async function afterGatewayTimeout(id, kind, before, error, { sync, request, progress }) {
|
|
174
|
+
let after = null;
|
|
175
|
+
try {
|
|
176
|
+
after = (await request("GET", `/api/v1/design/change-sets/${encode(id)}`)).data;
|
|
177
|
+
} catch {}
|
|
178
|
+
const status = error.code.replace("HTTP_", "HTTP ");
|
|
179
|
+
if (before?.state === ENTRY[kind] && after?.state === "in_review") {
|
|
180
|
+
progress(`Dev answered ${status} (the edge's gateway), but the submit landed: ${id} is ${after.state}. Its warnings were not returned; run nodii-dev figma changeset show ${id}.`);
|
|
181
|
+
return { data: after, warnings: [], landedAfterGatewayTimeout: true };
|
|
182
|
+
}
|
|
183
|
+
throw new CliError(
|
|
184
|
+
"SUBMIT_UNCONFIRMED",
|
|
185
|
+
[
|
|
186
|
+
`Dev answered ${status} (the edge's gateway timeout)${after ? ` and ${id} is still ${after.state}` : `, and ${id} could not be read`}.`,
|
|
187
|
+
"Dev may still be working on it: do not send it again yet.",
|
|
188
|
+
`Check with nodii-dev figma changeset show ${id} in a few minutes; if nothing changed, submit again${sync ? " without --sync, so Dev runs it as a job" : ""}.`,
|
|
189
|
+
].join("\n"),
|
|
190
|
+
1,
|
|
191
|
+
{ changeSetId: id, state: after?.state ?? null, gateway: error.code },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A submit or re-submit, async unless `sync`; a gateway timeout is checked against the set, not resent. */
|
|
196
|
+
async function submitCall(id, kind, body, { sync = false, request = devRequest, progress = stderrLine, ...job } = {}) {
|
|
197
|
+
const path = `/api/v1/design/change-sets/${encode(id)}/${kind}`;
|
|
198
|
+
let before = null;
|
|
199
|
+
try {
|
|
200
|
+
before = (await request("GET", `/api/v1/design/change-sets/${encode(id)}`)).data;
|
|
201
|
+
} catch {}
|
|
202
|
+
try {
|
|
203
|
+
return sync ? await request("POST", path, body) : await changeSetJob("POST", path, body, { changeSetId: id, kind, request, progress, ...job });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!GATEWAY.has(error?.code)) throw error;
|
|
206
|
+
return afterGatewayTimeout(id, kind, before, error, { sync, request, progress });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function figmaResubmit(id, file, { request = devRequest, read = (f) => readFile(f, "utf8"), ...options } = {}) {
|
|
59
211
|
if (!id || !file) throw new CliError("USAGE", "figma resubmit CHANGE_SET PAYLOAD.json", 2);
|
|
60
212
|
let text;
|
|
61
213
|
try {
|
|
@@ -65,7 +217,7 @@ export async function figmaResubmit(id, file, { request = devRequest, read = (f)
|
|
|
65
217
|
}
|
|
66
218
|
const body = resubmitPayload(text, file);
|
|
67
219
|
try {
|
|
68
|
-
return await
|
|
220
|
+
return await submitCall(id, "resubmit", body, { request, ...options });
|
|
69
221
|
} catch (error) {
|
|
70
222
|
const say = RESUBMIT_REFUSALS[error?.code];
|
|
71
223
|
if (!say) throw scopeRefusal(error, id) ?? error;
|
|
@@ -185,6 +337,11 @@ export function conformanceLines(r) {
|
|
|
185
337
|
lines.push(`${r.libraryUpdatePending.length} node(s) behind a library republish (not failures; accept the library update in this file, MW6-17):`);
|
|
186
338
|
for (const f of r.libraryUpdatePending) lines.push(` ${f.nodeId}\t${f.check ?? "libraryUpdatePending"}\t${f.detail}`);
|
|
187
339
|
}
|
|
340
|
+
// Email is outside the app-UI gate (Dev issue 116): counted, never silent.
|
|
341
|
+
if (r.outOfScope?.length) {
|
|
342
|
+
lines.push(`out of scope: ${r.outOfScopeNodes ?? "?"} node(s) in ${r.outOfScope.length} part(s), not checked (email: the Email kit, email bodies, the block palette):`);
|
|
343
|
+
for (const f of r.outOfScope) lines.push(` ${f.nodeId}\toutOfScope\t${f.detail}`);
|
|
344
|
+
}
|
|
188
345
|
// Warnings never refuse: STALE_FIGMA_VERSION is one (Dev issue 92, until
|
|
189
346
|
// Dev issue 107). With staleVersion, its wait is printed below instead.
|
|
190
347
|
const warnings = (r.warnings ?? []).filter((f) => !(r.staleVersion && f.check === "STALE_FIGMA_VERSION"));
|
|
@@ -314,12 +471,15 @@ export const figmaScopeSetText = (id, data) =>
|
|
|
314
471
|
...(data?.released?.length ? [`Released for other sets: ${data.released.map(scopeText).join(", ")}`] : []),
|
|
315
472
|
].join("\n");
|
|
316
473
|
|
|
317
|
-
|
|
318
|
-
|
|
474
|
+
/** A submit, async unless `sync` (options: sync, request, send, progress, sleep). */
|
|
475
|
+
export const figmaSubmit = (id, payload, { request = devRequest, ...options } = {}) =>
|
|
476
|
+
changeSetCall(id, (method, path, body) => submitCall(id, "submit", body, { request, ...options }))(
|
|
477
|
+
"POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
|
|
319
478
|
|
|
320
|
-
/** checkDesignConformance: the submit gate's dry run on a change set; changes no state. Body: { figmaVersion?, items? }. */
|
|
321
|
-
export const figmaConformance = (id, body = {}, { request = devRequest } = {}) =>
|
|
322
|
-
changeSetCall(id, request)(
|
|
479
|
+
/** checkDesignConformance: the submit gate's dry run on a change set; changes no state. Body: { figmaVersion?, items? }. Async unless `sync`. */
|
|
480
|
+
export const figmaConformance = (id, body = {}, { request = devRequest, sync = false, ...options } = {}) =>
|
|
481
|
+
changeSetCall(id, (method, path, b) => (sync ? request(method, path, b) : changeSetJob(method, path, b, { changeSetId: id, kind: "conformance", request, ...options })))(
|
|
482
|
+
"POST", `/api/v1/design/change-sets/${encode(id)}/conformance`, body);
|
|
323
483
|
|
|
324
484
|
export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {}) =>
|
|
325
485
|
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/writes`, { tool, nodeIds });
|
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) {
|