@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/dist/figma.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { CliError, devRequest, encode } from "./core.js";
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
- export async function figmaResubmit(id, file, { request = devRequest, read = (f) => readFile(f, "utf8") } = {}) {
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 request("POST", `/api/v1/design/change-sets/${encode(id)}/resubmit`, body);
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
- export const figmaSubmit = (id, payload, { request = devRequest } = {}) =>
318
- changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
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)("POST", `/api/v1/design/change-sets/${encode(id)}/conformance`, body);
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
- 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) {