@nodii/dev 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,9 +24,15 @@ live `devops.*` key except `devops.releases.promote`, which is never granted.
24
24
  Login uses the RFC 8628 device flow and opens the verification page on an
25
25
  interactive terminal unless `--no-browser` is passed. Refresh tokens are
26
26
  stored only in macOS Keychain or Linux Secret Service (`secret-tool`); the CLI
27
- refuses plaintext fallback. Access tokens stay in memory. Every API call goes
28
- to the edge's `/devops` prefix with the OAuth bearer and the registered
29
- `x-agent-id`. `logout` revokes the refresh chain before erasing local
27
+ refuses plaintext fallback. The current access token is kept there too, so
28
+ every nodii-dev process on the machine shares it instead of refreshing on each
29
+ run; refreshes take turns under a per-user lock. A refresh clears the saved
30
+ sign-in only when nodii-auth answers `invalid_grant` (expired, revoked or
31
+ replayed). A 429, 5xx, timeout, network error or proxy error page keeps it and
32
+ fails with `OAUTH_REFRESH_UNAVAILABLE` (exit 1) after a few retries. Every API
33
+ call goes to the edge's `/devops` prefix with the OAuth bearer and the
34
+ registered `x-agent-id`; signing in again as the same person keeps that
35
+ registration. `logout` revokes the refresh chain before erasing local
30
36
  credentials. `--auth-url`, `--edge-url`, and `--tenant-hint` select another real
31
37
  deployment.
32
38
 
@@ -39,7 +45,9 @@ asks you to run `nodii-dev login`.
39
45
  `nodii-dev` is only the Dev control CLI. The local-stack CLI is `./nodii-stack`,
40
46
  run from the `nodii-local-stack` checkout (renamed per operator ruling G3-6,
41
47
  with no alias under the old name). Typing one of its verbs here, such as
42
- `nodii-dev doctor`, fails and names the `./nodii-stack` command instead.
48
+ `nodii-dev provenance`, fails and names the `./nodii-stack` command instead.
49
+ `nodii-dev doctor` is this CLI's own preflight (below); `./nodii-stack doctor`
50
+ checks the local stack.
43
51
 
44
52
  Every command accepts `--json`; without it, output is formatted for humans.
45
53
  Dev failures print Dev's error code to stderr and exit non-zero.
@@ -51,6 +59,7 @@ nodii-dev login [--no-browser] [--scope KEYS]
51
59
  nodii-dev logout
52
60
  nodii-dev whoami # OAuth identity plus the registered agent
53
61
  nodii-dev config show
62
+ nodii-dev doctor [--json] # preflight: everything a lane needs, read-only
54
63
 
55
64
  nodii-dev api list [--tag TAG] [--json]
56
65
  nodii-dev api OPERATION_ID [--param name=value ...] [--body @file|-|JSON]
@@ -246,10 +255,32 @@ source documents moved, and each file created, updated or removed. It
246
255
  removes only files, blocks and hook entries it wrote, and it refuses (writing
247
256
  nothing) when one of its files was edited by hand.
248
257
 
258
+ ## Preflight: `doctor`
259
+
260
+ Run `nodii-dev doctor` from the lane's worktree, with the lane agent's config,
261
+ before `lane start`. It only reads, and prints one line per check: `OK`,
262
+ `MISSING` (with the command that fixes it) or `CANNOT CHECK` (with why). It
263
+ exits 0 only when every check is `OK`. `--json` returns the same checks as
264
+ `{ data: { ok, configPath, configSource, checks: [{ key, status, message, fix?, ... }] } }`.
265
+
266
+ | Check | What it reads |
267
+ | --- | --- |
268
+ | `config` | Which config file is in use: `NODII_DEV_CONFIG_PATH` or the default `~/.config/nodii-dev/config.json`, and whether it is an OAuth login. |
269
+ | `signed_in` | The refresh credential in the keychain, and an access token from it. When nodii-auth rejects the sign-in, it says whether the grant **expired**, was **revoked** or was **replaced**, if the server named which (otherwise it says the server did not). A rejected credential is reported, never cleared. |
270
+ | `agent` | The agent on this config (`agentId`), as Dev reads it back. |
271
+ | `permissions` | The sign-in's permissions against what lane commands need (`devops.issues`, `lanes`, `agents` `.read`/`.write`, `devops.machines.manage`, `devops.decisions.read`/`.write`/`.lock` for Figma locks, `devops.rulebook.read`). Each missing key is listed, with whether the installation has not consented to it or no role grants it. |
272
+ | `machine` | The machine this agent is assigned to and its coordinator. No machine means `lane start` would fail with `MACHINE_REQUIRED`. |
273
+ | `repo` | Whether the repository in this directory is the agent's home repository or granted to it. Repositories are compared by short name (`nodii-clis`, not `owner/nodii-clis`), as Dev compares them. Dev does not let an OAuth agent read its own grants yet (requested in Dev), so a repository other than the home one is `CANNOT CHECK`. |
274
+ | `lane_limit` | The agent's active lanes against its lane limit. A full limit is `OK` when a lane on this branch holds the slot. |
275
+
276
+ Doctor may refresh the access token, as any command does. The permission table
277
+ is kept by hand from Dev's route policy until Dev's OpenAPI names each
278
+ operation's permission (requested in Dev).
279
+
249
280
  ## Build lanes and repository setup
250
281
 
251
- `lane start` creates and starts a lane. It infers the repository, branch and
252
- worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
282
+ `lane start` creates and starts a lane. It infers the repository (by short
283
+ name, as Dev compares it), branch and worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
253
284
  required by Dev. Lane keys are passed directly to every lane and Built
254
285
  acknowledgement route. `next` reads the lane's generated
255
286
  API steps and prints the exact next `lane step` command. Server refusals,
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import { heartbeatCurrentLane, postHook, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
26
26
  import { setupCommand } from "./setup.js";
27
27
  import { harnessCommand } from "./harness.js";
28
+ import { doctorCommand } from "./doctor.js";
28
29
  import { docsCommand, rulebookCommand } from "./upload.js";
29
30
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
30
31
  import {
@@ -54,6 +55,7 @@ const HELP = `nodii-dev ${VERSION}
54
55
  Usage: nodii-dev <command> [options] [--json]
55
56
 
56
57
  login | logout | whoami | config show
58
+ doctor [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots
57
59
  agent register --name NAME | agent heartbeat
58
60
  issues list|show|create|update|claim|release
59
61
  inbox list|show
@@ -101,7 +103,8 @@ Run a command with --help or see the package README for all flags.`;
101
103
 
102
104
  // Verbs of the local-stack CLI, which shared this name until it became
103
105
  // ./nodii-stack (operator G3-6, no alias). Named here only to point there.
104
- const STACK_VERBS = new Set(["up", "down", "doctor", "sync", "seed", "wipe", "reset", "rebuild", "restart", "smoke", "urls", "peek", "trace", "stats", "bootstrap", "provenance", "stale", "status", "logs", "world", "totp", "token", "bench"]);
106
+ // `doctor` is not one of them: `nodii-dev doctor` is this CLI's own preflight.
107
+ const STACK_VERBS = new Set(["up", "down", "sync", "seed", "wipe", "reset", "rebuild", "restart", "smoke", "urls", "peek", "trace", "stats", "bootstrap", "provenance", "stale", "status", "logs", "world", "totp", "token", "bench"]);
105
108
 
106
109
  async function stdinText() {
107
110
  let value = "";
@@ -145,9 +148,12 @@ async function login(flags, json) {
145
148
  noBrowser: Boolean(flags["no-browser"]),
146
149
  scope: flags.scope,
147
150
  output: json ? console.error : console.log,
151
+ previous,
148
152
  });
149
- return emit({ loggedIn: true, provider: "oauth", credentialStorage: result.storage }, json,
150
- `OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
153
+ return emit({ loggedIn: true, provider: "oauth", credentialStorage: result.storage, agentId: result.agentId }, json,
154
+ result.agentId
155
+ ? `OAuth login complete; refresh credential stored in ${result.storage}. Kept agent registration ${result.agentId}.`
156
+ : `OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
151
157
  }
152
158
 
153
159
  async function issues(action, args, f, json) {
@@ -269,6 +275,13 @@ export async function main(argv = process.argv.slice(2)) {
269
275
  }
270
276
  const emitCommand = async (result) => emit(result.payload, json, result.human);
271
277
  if (command === "setup") return emitCommand(await setupCommand(f));
278
+ if (command === "doctor") {
279
+ if (action) throw new CliError("USAGE", `doctor takes no arguments, got: ${[action, ...rest].join(" ")}`, 2);
280
+ const result = await doctorCommand();
281
+ emit(result.payload, json, result.human);
282
+ if (result.exitCode) process.exitCode = result.exitCode;
283
+ return;
284
+ }
272
285
  if (command === "harness" && ["install", "update"].includes(action)) {
273
286
  // `--include-drafts` is a switch: `--include-drafts false` would leave `false` here and turn drafts on.
274
287
  if (rest.length) throw new CliError("USAGE", `harness ${action} takes no arguments, got: ${rest.join(" ")}. Write a flag value as --include-drafts=false.`, 2);
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.2";
7
+ export const VERSION = "0.6.4";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
package/dist/doctor.js ADDED
@@ -0,0 +1,235 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { devRequest, encode, isOAuthConfig, query, setAuthProvider } from "./core.js";
5
+ import { OAuthAuthProvider, decodeClaims, loadRefreshToken } from "./oauth.js";
6
+ import { inferGitContext, repoName } from "./lanes.js";
7
+
8
+ /**
9
+ * What a lane's commands need, in Dev's route policy (staffRuleFor in Dev's
10
+ * src/lib/auth/staff-policy.ts): an area's reads need `.read` and its writes
11
+ * `.write`; every machine route needs `machines.manage`; Figma file locks live
12
+ * in the decisions area, and releasing one needs `decisions.lock`. Dev's
13
+ * OpenAPI does not name the permission per operation, so this table is kept
14
+ * by hand.
15
+ */
16
+ export const LANE_PERMISSIONS = [
17
+ { key: "devops.issues.read", for: "issues list|show" },
18
+ { key: "devops.issues.write", for: "issues create|update|claim|release, api transitionIssue" },
19
+ { key: "devops.lanes.read", for: "lane show|list, next" },
20
+ { key: "devops.lanes.write", for: "lane start|step|evidence|heartbeat|review|land|verify" },
21
+ { key: "devops.agents.read", for: "whoami, reading this agent" },
22
+ { key: "devops.agents.write", for: "agent register|heartbeat" },
23
+ { key: "devops.machines.manage", for: "machine show (this agent's machine and coordinator)" },
24
+ { key: "devops.decisions.read", for: "figma check" },
25
+ { key: "devops.decisions.write", for: "figma lock|log-write|submit|apply|published|abandon" },
26
+ { key: "devops.decisions.lock", for: "figma release" },
27
+ { key: "devops.rulebook.read", for: "rulebook show" },
28
+ ];
29
+
30
+ // Lane statuses that hold one of the agent's lane slots (Dev's ACTIVE set).
31
+ const ACTIVE = new Set(["queued", "building", "built", "review", "on_main"]);
32
+ const LABEL = { ok: "OK", missing: "MISSING", cannot_check: "CANNOT CHECK" };
33
+
34
+ const ok = (key, message, extra = {}) => ({ key, status: "ok", message, ...extra });
35
+ const missing = (key, message, fix, extra = {}) => ({ key, status: "missing", message, ...(fix ? { fix } : {}), ...extra });
36
+ const cannot = (key, message, extra = {}) => ({ key, status: "cannot_check", message: `cannot check: ${message}`, ...extra });
37
+ const devReason = (error) => [error.code, error.details?.reason].filter(Boolean).join(" ") + (error.message ? `: ${error.message}` : "");
38
+
39
+ /** expired, revoked, replaced, or unspecified — only what nodii-auth said. */
40
+ export function grantReason(grant = {}) {
41
+ if (grant.error === "expired_token") return "expired";
42
+ if (grant.error === "token_revoked") return "revoked";
43
+ const said = String(grant.description ?? "").toLowerCase();
44
+ if (/revok/.test(said)) return "revoked";
45
+ if (/expir/.test(said)) return "expired";
46
+ if (/reuse|replay|rotat|replac|supersed/.test(said)) return "replaced";
47
+ return "unspecified";
48
+ }
49
+
50
+ async function readConfigFile(path) {
51
+ let raw;
52
+ try {
53
+ raw = await readFile(path, "utf8");
54
+ } catch (error) {
55
+ if (error?.code === "ENOENT") return null;
56
+ return { unreadable: error?.code ?? String(error) };
57
+ }
58
+ try { return JSON.parse(raw); } catch { return {}; }
59
+ }
60
+
61
+ /** A provider that never clears a rejected credential: doctor only looks. */
62
+ export function lookingProvider(options = {}) {
63
+ const provider = new OAuthAuthProvider({ ...options, deleteToken: async () => {}, deleteAccess: async () => {} });
64
+ setAuthProvider(provider);
65
+ return provider;
66
+ }
67
+
68
+ async function allLanes(request, agentId) {
69
+ const lanes = [];
70
+ let cursor;
71
+ do {
72
+ const page = await request("GET", `/api/v1/lanes${query({ agent: agentId, cursor, limit: 200 })}`);
73
+ lanes.push(...(page.data ?? []));
74
+ cursor = page.nextCursor ?? undefined;
75
+ } while (cursor);
76
+ return lanes;
77
+ }
78
+
79
+ /**
80
+ * `nodii-dev doctor`: read-only preflight of everything a lane needs, so a
81
+ * missing permission, machine or grant shows before the lane starts.
82
+ */
83
+ export async function doctorCommand(deps = {}) {
84
+ const env = deps.env ?? process.env;
85
+ const path = env.NODII_DEV_CONFIG_PATH ?? (deps.defaultConfigPath ?? (() => join(homedir(), ".config/nodii-dev/config.json")))();
86
+ const configSource = env.NODII_DEV_CONFIG_PATH ? "NODII_DEV_CONFIG_PATH" : "default";
87
+ const request = deps.request ?? devRequest;
88
+ const prefix = env.NODII_DEV_CONFIG_PATH ? `NODII_DEV_CONFIG_PATH=${env.NODII_DEV_CONFIG_PATH} ` : "";
89
+ const loginFix = `${prefix}nodii-dev login`;
90
+ const checks = [];
91
+
92
+ // 1. Config, and which file.
93
+ const config = await (deps.readConfigFile ?? readConfigFile)(path);
94
+ const configOk = isOAuthConfig(config);
95
+ if (config === null) checks.push(missing("config", `no config at ${path} (${configSource})`, loginFix));
96
+ else if (config.unreadable) checks.push(missing("config", `cannot read ${path} (${config.unreadable})`, `check the file's permissions, or ${loginFix}`));
97
+ else if (!configOk) checks.push(missing("config", `${path} is not an OAuth login (written before 0.5.0, or not valid JSON)`, loginFix));
98
+ else checks.push(ok("config", `${path} (${configSource})`));
99
+
100
+ // 2. Signed in: a stored credential, and an access token from it.
101
+ let claims = null;
102
+ if (!configOk) checks.push(missing("signed_in", "not signed in", loginFix));
103
+ else {
104
+ let stored = false;
105
+ try {
106
+ await (deps.loadRefreshToken ?? loadRefreshToken)(config.clientId);
107
+ stored = true;
108
+ } catch {
109
+ checks.push(missing("signed_in", "no stored sign-in credential for this app in the keychain", loginFix));
110
+ }
111
+ if (stored) {
112
+ try {
113
+ const token = await (deps.accessToken ?? (() => lookingProvider().accessToken()))();
114
+ claims = (deps.decodeClaims ?? decodeClaims)(token);
115
+ const minutes = Number.isFinite(claims.exp) ? Math.max(0, Math.floor((claims.exp * 1000 - Date.now()) / 60_000)) : null;
116
+ checks.push(ok("signed_in", `as person ${claims.sub ?? config.person} through installation ${config.installationId ?? "unknown"}${minutes === null ? "" : `; access token valid ${minutes} min`}`,
117
+ { person: claims.sub ?? config.person, installationId: config.installationId ?? null }));
118
+ } catch (error) {
119
+ if (error?.code === "OAUTH_LOGIN_REQUIRED" && error.details?.grant) {
120
+ const reason = grantReason(error.details.grant);
121
+ const why = reason === "unspecified"
122
+ ? `nodii-auth rejected the sign-in (${error.details.grant.error ?? "invalid_grant"}) and did not say whether it expired, was revoked or was replaced`
123
+ : `nodii-auth rejected the sign-in: ${reason}`;
124
+ checks.push(missing("signed_in", why, loginFix, { reason }));
125
+ } else if (error?.code === "OAUTH_LOGIN_REQUIRED") checks.push(missing("signed_in", "no stored sign-in credential for this app in the keychain", loginFix));
126
+ else if (error?.code === "OAUTH_REFRESH_FAILED") checks.push(missing("signed_in", error.message, loginFix));
127
+ else checks.push(cannot("signed_in", `${error?.code ?? "ERROR"}: ${error?.message ?? error}`));
128
+ }
129
+ }
130
+ }
131
+ const signedIn = claims !== null;
132
+
133
+ // 3. Agent registered on this config and recognised by Dev.
134
+ let agent = null;
135
+ const registerFix = `${prefix}nodii-dev agent register --name NAME`;
136
+ if (!signedIn) checks.push(cannot("agent", "needs a sign-in"));
137
+ else if (!config.agentId) checks.push(missing("agent", `no agent registered on ${path}`, registerFix));
138
+ else {
139
+ try {
140
+ agent = (await request("GET", `/api/v1/agents/${encode(config.agentId)}`)).data;
141
+ if (agent.status === "disconnected") {
142
+ checks.push(missing("agent", `agent ${agent.name} (${agent.id}) is disconnected`, registerFix));
143
+ agent = null;
144
+ } else checks.push(ok("agent", `${agent.name} (${agent.id}), ${agent.status}, role ${agent.role}`, { agentId: agent.id, name: agent.name }));
145
+ } catch (error) {
146
+ checks.push(missing("agent", `Dev does not recognise agent ${config.agentId} (${devReason(error)})`, registerFix));
147
+ }
148
+ }
149
+
150
+ // 4. Permissions: the installation's consent and the person's roles, as the token carries them.
151
+ if (!signedIn) checks.push(cannot("permissions", "needs a sign-in"));
152
+ else if (!Array.isArray(claims.permissions)) checks.push(cannot("permissions", "the access token carries no permissions claim"));
153
+ else {
154
+ const granted = new Set(claims.permissions);
155
+ const consented = new Set(Array.isArray(claims.grant_scope) ? claims.grant_scope : claims.permissions);
156
+ const gaps = LANE_PERMISSIONS.filter((x) => !granted.has(x.key)).map((x) => ({ key: x.key, for: x.for, why: consented.has(x.key) ? "no_role" : "not_consented" }));
157
+ if (!gaps.length) checks.push(ok("permissions", `all ${LANE_PERMISSIONS.length} permissions a lane needs are granted`, { missing: [] }));
158
+ else {
159
+ const text = gaps.map((x) => `${x.key} (${x.why === "not_consented" ? "installation has not consented" : "consented, but no role grants it"}; for ${x.for})`).join(", ");
160
+ const fixes = [
161
+ ...(gaps.some((x) => x.why === "not_consented") ? [`re-consent the nodii-dev installation, then ${loginFix}`] : []),
162
+ ...(gaps.some((x) => x.why === "no_role") ? ["ask a Dev admin for a role that grants the missing keys, then sign in again"] : []),
163
+ ];
164
+ checks.push(missing("permissions", `missing ${text}`, fixes.join("; "), { missing: gaps }));
165
+ }
166
+ }
167
+
168
+ // 5. Machine and its coordinator.
169
+ if (!agent) checks.push(cannot("machine", "needs a registered agent"));
170
+ else if (!agent.machineId)
171
+ checks.push(missing("machine", "this agent is not assigned to a machine; lane start fails with MACHINE_REQUIRED",
172
+ `ask the machine's coordinator: nodii-dev machine assign <machine> ${agent.id} --home-repo <repo>`));
173
+ else {
174
+ try {
175
+ const machine = (await request("GET", `/api/v1/machines/${encode(agent.machineId)}`)).data;
176
+ const coordinator = (machine.agents ?? []).find((x) => x.id === machine.coordinatorAgentId);
177
+ checks.push(ok("machine", `${machine.name} (${machine.id}); coordinator ${machine.coordinatorAgentId ? `${coordinator?.name ?? "agent"} (${machine.coordinatorAgentId})` : "none set"}`,
178
+ { machineId: machine.id, machineName: machine.name, coordinatorAgentId: machine.coordinatorAgentId ?? null, coordinatorName: coordinator?.name ?? null }));
179
+ } catch (error) {
180
+ checks.push(cannot("machine", `assigned to ${agent.machineId}, but getMachine failed (${devReason(error)})`, { machineId: agent.machineId }));
181
+ }
182
+ }
183
+
184
+ // 6. Home repo or a grant for the repository in this directory, by short name.
185
+ let context = null;
186
+ if (!agent) checks.push(cannot("repo", "needs a registered agent"));
187
+ else {
188
+ try {
189
+ context = await (deps.gitContext ?? inferGitContext)();
190
+ } catch (error) {
191
+ checks.push(cannot("repo", `${error?.code === "GIT_REQUIRED" ? "not in a git worktree on a branch" : (error?.message ?? error)}; run doctor from the lane's worktree`));
192
+ }
193
+ if (context) {
194
+ const repo = repoName(context.repo);
195
+ if (agent.homeRepo && repoName(agent.homeRepo) === repo) checks.push(ok("repo", `${repo} is this agent's home repository`, { repo, homeRepo: agent.homeRepo }));
196
+ else {
197
+ try {
198
+ const grants = (await request("GET", `/api/v1/agents/${encode(agent.id)}/repo-grants`)).data ?? [];
199
+ if (grants.some((x) => !x.revokedAt && repoName(x.repo) === repo)) checks.push(ok("repo", `${repo} is granted to this agent (home ${agent.homeRepo ?? "none"})`, { repo, homeRepo: agent.homeRepo ?? null }));
200
+ else checks.push(missing("repo", `${repo} is neither the home repository (${agent.homeRepo ?? "none"}) nor granted; lane start fails with REPO_NOT_GRANTED`,
201
+ `nodii-dev agent grant ${agent.id} ${repo}`, { repo, homeRepo: agent.homeRepo ?? null }));
202
+ } catch (error) {
203
+ checks.push(cannot("repo", `${repo} is not the home repository (${agent.homeRepo ?? "none"}), and listAgentRepoGrants failed (${devReason(error)})`, { repo, homeRepo: agent.homeRepo ?? null }));
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ // 7. Lane limit: a free slot, or a lane on this branch already holding one.
210
+ if (!agent) checks.push(cannot("lane_limit", "needs a registered agent"));
211
+ else {
212
+ try {
213
+ const active = (await allLanes(request, agent.id)).filter((x) => ACTIVE.has(x.status));
214
+ const limit = agent.laneLimit ?? 1;
215
+ const here = (x) => x.branch === context.branch && (!Array.isArray(x.repos) || x.repos.map(repoName).includes(repoName(context.repo)));
216
+ const mine = context && active.find(here);
217
+ const keys = active.map((x) => x.key).join(", ");
218
+ const extra = { active: active.length, limit, lanes: active.map((x) => x.key) };
219
+ if (active.length < limit) checks.push(ok("lane_limit", `${active.length} of ${limit} lane slots in use${keys ? ` (${keys})` : ""}`, extra));
220
+ else if (mine) checks.push(ok("lane_limit", `${active.length} of ${limit} lane slots in use; ${mine.key} on this branch holds one`, extra));
221
+ else checks.push(missing("lane_limit", `${active.length} of ${limit} lane slots in use (${keys}); lane start fails with LANE_LIMIT_REACHED`,
222
+ `land or stop a lane, or ask the coordinator: nodii-dev agent lane-limit ${agent.id} <n>`, extra));
223
+ } catch (error) {
224
+ checks.push(cannot("lane_limit", `listLanes failed (${devReason(error)})`));
225
+ }
226
+ }
227
+
228
+ const ready = checks.every((x) => x.status === "ok");
229
+ const width = Math.max(...checks.map((x) => x.key.length));
230
+ const human = (p) => [
231
+ ...p.data.checks.map((x) => `${LABEL[x.status].padEnd(12)} ${x.key.padEnd(width)} ${x.message}${x.fix ? ` — fix: ${x.fix}` : ""}`),
232
+ p.data.ok ? "Ready for a lane." : `Not ready: ${p.data.checks.filter((x) => x.status !== "ok").map((x) => x.key).join(", ")}.`,
233
+ ].join("\n");
234
+ return { payload: { data: { ok: ready, configPath: path, configSource, checks } }, human, exitCode: ready ? 0 : 1 };
235
+ }
package/dist/lanes.js CHANGED
@@ -18,15 +18,21 @@ async function git(...args) {
18
18
  }
19
19
  }
20
20
 
21
+ /**
22
+ * A repository as Dev names it: the short name. Dev compares a lane's repos
23
+ * exactly against the agent's home repo and grants, which are short names, so
24
+ * `owner/name` from the remote was refused as REPO_NOT_GRANTED.
25
+ */
26
+ export const repoName = (remoteOrPath) => String(remoteOrPath).replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).filter(Boolean).at(-1);
27
+
21
28
  export async function inferGitContext() {
22
29
  const worktree = await git("rev-parse", "--show-toplevel");
23
30
  const branch = await git("branch", "--show-current");
24
31
  let repo;
25
32
  try {
26
- const remote = await git("remote", "get-url", "origin");
27
- repo = remote.replace(/\.git$/, "").replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "");
33
+ repo = repoName(await git("remote", "get-url", "origin"));
28
34
  } catch {
29
- repo = worktree.split("/").filter(Boolean).at(-1);
35
+ repo = repoName(worktree);
30
36
  }
31
37
  return { repo, branch: need(branch, "Detached HEAD: pass --branch."), worktree };
32
38
  }
package/dist/oauth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
- import { open, rm, stat } from "node:fs/promises";
3
- import { platform } from "node:os";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile, rm, stat, utimes } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
6
  import { CliError, configPath, readConfig, writeConfig } from "./core.js";
6
7
 
@@ -29,6 +30,7 @@ export function requestedScope(scope) {
29
30
 
30
31
  const service = "dev.nodii.cli.oauth";
31
32
  const account = (clientId) => `refresh:${clientId}`;
33
+ const accessAccount = (clientId) => `access:${clientId}`;
32
34
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
35
 
34
36
  function run(command, args, { input } = {}) {
@@ -43,42 +45,83 @@ function run(command, args, { input } = {}) {
43
45
  });
44
46
  }
45
47
 
46
- export async function storeRefreshToken(clientId, token, os = platform()) {
48
+ async function keychainStore(name, secret, os) {
47
49
  if (os === "darwin") {
48
- await run("security", ["add-generic-password", "-U", "-a", account(clientId), "-s", service, "-w", token]);
50
+ if (!/^[A-Za-z0-9_.:-]+$/.test(name)) throw new CliError("KEYCHAIN_UNAVAILABLE", "The OAuth client id has characters the Keychain helper cannot take; nothing was stored.", 2);
51
+ // Through `security -i` on stdin, so the secret never appears in argv
52
+ // (visible to `ps`). The interactive parser splits on whitespace and reads
53
+ // quotes, so a secret outside a token's own alphabet falls back to argv
54
+ // rather than being dropped (it may be a refresh token just rotated).
55
+ if (/^[A-Za-z0-9._~+/=-]+$/.test(secret)) await run("security", ["-i"], { input: `add-generic-password -U -a ${name} -s ${service} -w ${secret}\n` });
56
+ else await run("security", ["add-generic-password", "-U", "-a", name, "-s", service, "-w", secret]);
49
57
  return "macOS Keychain";
50
58
  }
51
59
  if (os === "linux") {
52
60
  try {
53
- await run("secret-tool", ["store", "--label=nodii-dev OAuth", "service", service, "account", account(clientId)], { input: token });
61
+ await run("secret-tool", ["store", "--label=nodii-dev OAuth", "service", service, "account", name], { input: secret });
54
62
  return "Secret Service";
55
63
  } catch {}
56
64
  }
57
65
  throw new CliError("KEYCHAIN_UNAVAILABLE", "OAuth refresh tokens require macOS Keychain or Linux Secret Service. No credential was stored; re-run login when a keychain is available.", 2);
58
66
  }
59
67
 
68
+ async function keychainLoad(name, os) {
69
+ if (os === "darwin") return (await run("security", ["find-generic-password", "-a", name, "-s", service, "-w"])).trim();
70
+ if (os === "linux") return (await run("secret-tool", ["lookup", "service", service, "account", name])).trim();
71
+ throw new Error("no keychain");
72
+ }
73
+
74
+ async function keychainDelete(name, os) {
75
+ try {
76
+ if (os === "darwin") await run("security", ["delete-generic-password", "-a", name, "-s", service]);
77
+ else if (os === "linux") await run("secret-tool", ["clear", "service", service, "account", name]);
78
+ } catch {}
79
+ }
80
+
81
+ export async function storeRefreshToken(clientId, token, os = platform()) {
82
+ return keychainStore(account(clientId), token, os);
83
+ }
84
+
60
85
  export async function loadRefreshToken(clientId, os = platform()) {
61
86
  try {
62
- if (os === "darwin") return (await run("security", ["find-generic-password", "-a", account(clientId), "-s", service, "-w"])).trim();
63
- if (os === "linux") return (await run("secret-tool", ["lookup", "service", service, "account", account(clientId)])).trim();
87
+ const token = await keychainLoad(account(clientId), os);
88
+ if (token) return token;
64
89
  } catch {}
65
90
  throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth credentials are unavailable; run `nodii-dev login` again.", 2);
66
91
  }
67
92
 
68
93
  export async function deleteRefreshToken(clientId, os = platform()) {
94
+ await keychainDelete(account(clientId), os);
95
+ }
96
+
97
+ // The access token is cached beside the refresh token so that every nodii-dev
98
+ // process on the machine shares one: without it each command rotated the
99
+ // refresh token, and N lanes rotated it N times as often.
100
+ export async function loadAccessToken(clientId, os = platform()) {
69
101
  try {
70
- if (os === "darwin") await run("security", ["delete-generic-password", "-a", account(clientId), "-s", service]);
71
- else if (os === "linux") await run("secret-tool", ["clear", "service", service, "account", account(clientId)]);
72
- } catch {}
102
+ const cached = JSON.parse(Buffer.from(await keychainLoad(accessAccount(clientId), os), "base64url").toString("utf8"));
103
+ return typeof cached?.token === "string" && Number.isFinite(cached.expiresAt) ? cached : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export async function storeAccessToken(clientId, cached, os = platform()) {
110
+ try { await keychainStore(accessAccount(clientId), Buffer.from(JSON.stringify(cached)).toString("base64url"), os); } catch {}
73
111
  }
74
112
 
75
- async function oauthPost(authUrl, path, fields, fetchImpl = fetch) {
113
+ export async function deleteAccessToken(clientId, os = platform()) {
114
+ await keychainDelete(accessAccount(clientId), os);
115
+ }
116
+
117
+ async function oauthPost(authUrl, path, fields, fetchImpl = fetch, timeoutMs = REFRESH_TIMEOUT_MS) {
76
118
  let response;
77
119
  try {
78
120
  response = await fetchImpl(`${authUrl.replace(/\/$/, "")}${path}`, {
79
121
  method: "POST",
80
122
  headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
81
123
  body: new URLSearchParams(fields),
124
+ signal: AbortSignal.timeout(timeoutMs),
82
125
  });
83
126
  } catch (error) {
84
127
  throw new CliError("AUTH_UNREACHABLE", `Cannot reach nodii-auth: ${error.message}`, 1);
@@ -112,7 +155,20 @@ function openBrowser(url) {
112
155
  child.unref();
113
156
  }
114
157
 
115
- export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUrl = DEFAULT_EDGE_URL, tenantHint = PLATFORM_TENANT, noBrowser = false, scope, fetchImpl = fetch, output = console.log }) {
158
+ /**
159
+ * The agent registration a new login keeps. Dev binds an agent to the person,
160
+ * the app and the installation, so signing in again as the same person through
161
+ * the same installation keeps it; anything else starts unregistered.
162
+ */
163
+ export function keptAgent(previous, { clientId, person, installationId, edgeUrl }) {
164
+ if (!previous?.agentId || !person) return null;
165
+ if (previous.clientId !== clientId || previous.person !== person) return null;
166
+ if ((previous.installationId ?? null) !== (installationId ?? null)) return null;
167
+ if (edgeUrl && previous.edgeUrl && previous.edgeUrl.replace(/\/$/, "") !== edgeUrl.replace(/\/$/, "")) return null;
168
+ return previous.agentId;
169
+ }
170
+
171
+ export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUrl = DEFAULT_EDGE_URL, tenantHint = PLATFORM_TENANT, noBrowser = false, scope, fetchImpl = fetch, output = console.log, previous = null, lock = withRefreshLock }) {
116
172
  const requested = requestedScope(scope);
117
173
  const start = await oauthPost(authUrl, "/oauth/device/authorize", {
118
174
  client_id: clientId, tenant_hint: tenantHint, ...(requested ? { scope: requested } : {}),
@@ -123,61 +179,287 @@ export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUr
123
179
  if (!noBrowser && process.stdout.isTTY) openBrowser(d.verification_uri_complete ?? d.verification_uri);
124
180
  const token = await pollDeviceToken({ authUrl, clientId, deviceCode: d.device_code, interval: d.interval, expiresIn: d.expires_in, fetchImpl });
125
181
  if (!token.refresh_token) throw new CliError("OAUTH_REFRESH_MISSING", "nodii-auth did not return a refresh token.", 1);
126
- const storage = await storeRefreshToken(clientId, token.refresh_token);
182
+ const storage = await lock(async () => {
183
+ const stored = await storeRefreshToken(clientId, token.refresh_token);
184
+ await deleteAccessToken(clientId);
185
+ return stored;
186
+ }, { clientId });
127
187
  const claims = decodeClaims(token.access_token);
128
- await writeConfig({ authProvider: "oauth", authUrl, edgeUrl, clientId, tenantHint, agentId: null, person: claims.sub ?? null, installationId: claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null });
129
- return { storage, claims };
188
+ const person = claims.sub ?? null;
189
+ const installationId = claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null;
190
+ const agentId = keptAgent(previous, { clientId, person, installationId, edgeUrl });
191
+ await writeConfig({ authProvider: "oauth", authUrl, edgeUrl, clientId, tenantHint, agentId, person, installationId });
192
+ return { storage, claims, agentId };
130
193
  }
131
194
 
132
195
  export function decodeClaims(token) {
133
196
  try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); } catch { return {}; }
134
197
  }
135
198
 
136
- export async function withRefreshLock(fn, { sleep = wait } = {}) {
137
- const path = join(dirname(configPath()), "oauth-refresh.lock");
138
- for (let attempt = 0; attempt < 300; attempt++) {
199
+ // ONE lock per OAuth client per user, beside the Keychain item it guards. It
200
+ // used to live next to the config file, so two processes with different
201
+ // NODII_DEV_CONFIG_PATH values shared the credential but not the lock.
202
+ export function refreshLockPath(clientId = DEFAULT_CLIENT_ID) {
203
+ return join(homedir(), ".config/nodii-dev", `oauth-refresh-${String(clientId).replace(/[^A-Za-z0-9_.-]/g, "_")}.lock`);
204
+ }
205
+
206
+ const LOCK_STALE_MS = 30_000;
207
+ const LOCK_LIVE_STALE_MS = 120_000;
208
+ const LOCK_BEAT_MS = 5_000;
209
+ const LOCK_WAIT_MS = 90_000;
210
+ const RECLAIM_STALE_MS = 10_000;
211
+
212
+ function ownerPid(owner) {
213
+ const pid = Number.parseInt(owner, 10);
214
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
215
+ }
216
+
217
+ function pidAlive(pid) {
218
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
219
+ }
220
+
221
+ async function readOwner(path) {
222
+ try { return await readFile(path, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
223
+ }
224
+
225
+ async function lockAge(path) {
226
+ try { return Date.now() - (await stat(path)).mtimeMs; } catch (error) { if (error.code === "ENOENT") return null; throw error; }
227
+ }
228
+
229
+ // A lock is reclaimable at once when its holder's pid is gone; when the file
230
+ // names no pid yet (being written, or left by 0.6.2) after `staleMs` without a
231
+ // touch; and from a live holder only after LOCK_LIVE_STALE_MS — far longer than
232
+ // any refresh, so a holder that slept with the laptop is not robbed on waking.
233
+ function reclaimable(owner, age, staleMs) {
234
+ const pid = ownerPid(owner);
235
+ if (pid !== null && !pidAlive(pid)) return true;
236
+ return age > Math.max(staleMs, pid === null ? staleMs : LOCK_LIVE_STALE_MS);
237
+ }
238
+
239
+ // Reclaims are serialized through `<lock>.reclaim`, taken with O_EXCL: two
240
+ // waiters that both judged the same dead lock cannot both delete — the second
241
+ // would otherwise delete the lock the first had just re-created.
242
+ async function reclaim(path, seen, staleMs) {
243
+ const guard = `${path}.reclaim`;
244
+ const mine = `${process.pid}:${randomUUID()}`;
245
+ let handle;
246
+ try {
247
+ handle = await open(guard, "wx", 0o600);
248
+ } catch (error) {
249
+ if (error.code !== "EEXIST") throw error;
250
+ const holder = await readOwner(guard);
251
+ const age = await lockAge(guard);
252
+ if (holder !== null && age !== null && age > RECLAIM_STALE_MS && (await readOwner(guard)) === holder) await rm(guard, { force: true });
253
+ return;
254
+ }
255
+ try {
256
+ try { await handle.writeFile(mine); } finally { await handle.close(); }
257
+ const owner = await readOwner(path);
258
+ const age = await lockAge(path);
259
+ if (owner === seen && age !== null && reclaimable(owner, age, staleMs) && (await readOwner(guard)) === mine) await rm(path, { force: true });
260
+ } finally {
261
+ if ((await readOwner(guard)) === mine) await rm(guard, { force: true });
262
+ }
263
+ }
264
+
265
+ async function acquire(path, { sleep, waitMs, staleMs }) {
266
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
267
+ const owner = `${process.pid}:${randomUUID()}`;
268
+ const deadline = Date.now() + waitMs;
269
+ while (Date.now() < deadline) {
270
+ let handle;
139
271
  try {
140
- const handle = await open(path, "wx", 0o600);
141
- try { return await fn(); } finally { await handle.close(); await rm(path, { force: true }); }
272
+ handle = await open(path, "wx", 0o600);
142
273
  } catch (error) {
143
274
  if (error.code !== "EEXIST") throw error;
144
- try {
145
- if (Date.now() - (await stat(path)).mtimeMs > 60_000) {
146
- await rm(path, { force: true });
147
- continue;
148
- }
149
- } catch (statError) {
150
- if (statError.code !== "ENOENT") throw statError;
151
- }
152
- await sleep(100);
275
+ const seen = await readOwner(path);
276
+ const age = await lockAge(path);
277
+ if (seen !== null && age !== null && reclaimable(seen, age, staleMs)) await reclaim(path, seen, staleMs);
278
+ else await sleep(50 + Math.floor(Math.random() * 100));
279
+ continue;
153
280
  }
281
+ try { await handle.writeFile(owner); } finally { await handle.close(); }
282
+ const beat = setInterval(async () => {
283
+ try { if ((await readOwner(path)) === owner) await utimes(path, new Date(), new Date()); } catch {}
284
+ }, Math.max(250, Math.min(LOCK_BEAT_MS, Math.floor(staleMs / 3))));
285
+ beat.unref();
286
+ return {
287
+ held: async () => (await readOwner(path)) === owner,
288
+ release: async () => {
289
+ clearInterval(beat);
290
+ if ((await readOwner(path)) === owner) await rm(path, { force: true });
291
+ },
292
+ };
293
+ }
294
+ throw new CliError("OAUTH_REFRESH_BUSY", "Timed out waiting for another nodii-dev token refresh; the saved sign-in was kept. Retry the command.", 1);
295
+ }
296
+
297
+ // 0.6.2 and earlier locked `oauth-refresh.lock` beside the config file. While
298
+ // both versions may run on one machine, this release takes that lock too
299
+ // (always after its own, so the order is fixed). 0.6.2 reclaims it after 60 s
300
+ // without a touch, which the heartbeat prevents.
301
+ export const legacyRefreshLockPath = () => join(dirname(configPath()), "oauth-refresh.lock");
302
+
303
+ /**
304
+ * Run `fn` holding the cross-process refresh lock. The holder writes its owner
305
+ * id into the file and touches it every few seconds; a waiter reclaims the
306
+ * lock only from a holder that exited or went quiet, one reclaimer at a time,
307
+ * and a holder only ever removes its own lock file. `fn` receives `held()`, to
308
+ * confirm the lock is still its own before acting on the shared credential.
309
+ */
310
+ export async function withRefreshLock(fn, { clientId, path = refreshLockPath(clientId), legacyPath = path === refreshLockPath(clientId) ? legacyRefreshLockPath() : null,
311
+ sleep = wait, waitMs = LOCK_WAIT_MS, staleMs = LOCK_STALE_MS } = {}) {
312
+ const locks = [await acquire(path, { sleep, waitMs, staleMs })];
313
+ try {
314
+ if (legacyPath && legacyPath !== path) locks.push(await acquire(legacyPath, { sleep, waitMs, staleMs: Math.max(staleMs, 60_000) }));
315
+ const held = async () => (await Promise.all(locks.map((l) => l.held()))).every(Boolean);
316
+ return await fn({ held });
317
+ } finally {
318
+ for (const l of locks.reverse()) await l.release();
154
319
  }
155
- throw new CliError("OAUTH_REFRESH_BUSY", "Timed out waiting for another nodii-dev token refresh.", 1);
156
320
  }
157
321
 
322
+ // What a refresh answer means for the stored credential. Only the server saying
323
+ // the GRANT is dead (RFC 6749 § 5.2 invalid_grant: expired, revoked, replayed)
324
+ // ends the sign-in. Everything else — 429, 5xx, a timeout, no network, an HTML
325
+ // error page from a proxy or CDN, a 2xx without tokens — says nothing about the
326
+ // grant, so the credential is kept.
327
+ const DEAD_GRANT_ERRORS = new Set(["invalid_grant", "expired_token", "token_revoked"]);
328
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
329
+ export const REFRESH_TIMEOUT_MS = 10_000;
330
+ const REFRESH_ATTEMPTS = 4;
331
+ const RETRY_AFTER_CAP_MS = 5_000;
332
+ // The whole refresh, retries included, fits well inside the lock's wait
333
+ // (LOCK_WAIT_MS) so a waiter behind it sees the real outcome, not BUSY.
334
+ export const REFRESH_BUDGET_MS = 25_000;
335
+ // No request starts with less than this left: one cut off after the server
336
+ // rotated the token would lose the new token and replay the old one next run.
337
+ const MIN_ATTEMPT_MS = 5_000;
338
+
339
+ export function classifyRefresh(result) {
340
+ if (result.ok && result.body.access_token && result.body.refresh_token) return "ok";
341
+ if (result.status >= 400 && result.status < 500 && DEAD_GRANT_ERRORS.has(result.body.error)) return "dead";
342
+ if (result.status === 0 || result.ok || RETRYABLE_STATUS.has(result.status) || result.status >= 500 || !result.json) return "retry";
343
+ return "refused";
344
+ }
345
+
346
+ async function postRefresh(authUrl, clientId, refreshToken, fetchImpl, timeoutMs) {
347
+ let response;
348
+ try {
349
+ response = await fetchImpl(`${authUrl.replace(/\/$/, "")}/oauth/token`, {
350
+ method: "POST",
351
+ headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
352
+ body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: refreshToken }),
353
+ signal: AbortSignal.timeout(timeoutMs),
354
+ });
355
+ } catch (error) {
356
+ return { ok: false, status: 0, body: {}, json: false, reason: error?.name === "TimeoutError" ? "timeout" : "unreachable" };
357
+ }
358
+ let raw = "";
359
+ try { raw = await response.text(); } catch {}
360
+ let body = {};
361
+ let json = false;
362
+ try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { body = parsed; json = true; } } catch {}
363
+ const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") ?? "", 10);
364
+ return { ok: response.ok, status: response.status, body, json, retryAfterMs: Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined };
365
+ }
366
+
367
+ const describe = (result) => result.status === 0 ? (result.reason === "timeout" ? "timed out" : "nodii-auth unreachable") : `HTTP ${result.status}${result.json && typeof result.body.error === "string" ? ` ${result.body.error}` : ""}`;
368
+
158
369
  export class OAuthAuthProvider {
159
370
  #accessToken;
160
371
  #expiresAt = 0;
161
372
  #refreshPromise;
162
- constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock } = {}) {
163
- Object.assign(this, { fetchImpl, loadToken, storeToken, deleteToken, lock });
373
+ constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock,
374
+ loadAccess, storeAccess, deleteAccess, sleep = wait, timeoutMs = REFRESH_TIMEOUT_MS, budgetMs = REFRESH_BUDGET_MS } = {}) {
375
+ // Injected token storage (tests) gets no shared access cache unless one is
376
+ // injected too, so a test never reads or writes the real Keychain.
377
+ const realStorage = loadToken === loadRefreshToken;
378
+ Object.assign(this, { fetchImpl, loadToken, storeToken, deleteToken, lock, sleep, timeoutMs, budgetMs,
379
+ loadAccess: loadAccess ?? (realStorage ? loadAccessToken : async () => null),
380
+ storeAccess: storeAccess ?? (realStorage ? storeAccessToken : async () => {}),
381
+ deleteAccess: deleteAccess ?? (realStorage ? deleteAccessToken : async () => {}) });
382
+ }
383
+ #usable(cached) {
384
+ if (!cached || Date.now() >= cached.expiresAt - 60_000) return false;
385
+ this.#accessToken = cached.token;
386
+ this.#expiresAt = cached.expiresAt;
387
+ return true;
164
388
  }
165
389
  async accessToken() {
166
390
  if (this.#accessToken && Date.now() < this.#expiresAt - 30_000) return this.#accessToken;
167
- this.#refreshPromise ??= this.lock(async () => {
391
+ this.#refreshPromise ??= (async () => {
168
392
  const config = await readConfig();
169
- const refresh = await this.loadToken(config.clientId);
170
- const result = await oauthPost(config.authUrl, "/oauth/token", { grant_type: "refresh_token", client_id: config.clientId, refresh_token: refresh }, this.fetchImpl);
171
- if (!result.ok || !result.body.refresh_token) {
393
+ // Another process may have refreshed already: its access token is shared.
394
+ if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
395
+ return this.lock(async ({ held } = {}) => {
396
+ // Re-checked under the lock: a process that held it before us has just
397
+ // rotated the refresh token and cached the access token it got.
398
+ if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
399
+ return this.#refresh(config, held ?? (async () => true));
400
+ }, { clientId: config.clientId });
401
+ })().finally(() => { this.#refreshPromise = undefined; });
402
+ return this.#refreshPromise;
403
+ }
404
+ async #attempt(config, refresh, deadline, held) {
405
+ const floor = Math.min(MIN_ATTEMPT_MS, this.timeoutMs);
406
+ let result;
407
+ for (let attempt = 1; ; attempt++) {
408
+ if (result && deadline - Date.now() < floor) return { kind: classifyRefresh(result), result, attempts: attempt - 1 };
409
+ // Never present the refresh token without the lock: a second holder
410
+ // would present the same one, and the server revokes the chain on reuse.
411
+ if (!(await held())) throw new CliError("OAUTH_REFRESH_BUSY", "Lost the refresh lock to another nodii-dev process; the saved sign-in was kept. Retry the command.", 1);
412
+ result = await postRefresh(config.authUrl, config.clientId, refresh, this.fetchImpl, Math.max(floor, Math.min(this.timeoutMs, deadline - Date.now())));
413
+ const kind = classifyRefresh(result);
414
+ if (kind !== "retry" || attempt >= REFRESH_ATTEMPTS) return { kind, result, attempts: attempt };
415
+ const backoff = 500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250);
416
+ const pause = Math.min(Math.max(backoff, result.retryAfterMs ?? 0), RETRY_AFTER_CAP_MS);
417
+ if (Date.now() + pause + floor > deadline) return { kind, result, attempts: attempt };
418
+ await this.sleep(pause);
419
+ }
420
+ }
421
+ async #refresh(config, held) {
422
+ const deadline = Date.now() + this.budgetMs;
423
+ let refresh = await this.loadToken(config.clientId);
424
+ let outcome = await this.#attempt(config, refresh, deadline, held);
425
+ if (outcome.kind === "dead" || outcome.kind === "refused") {
426
+ // The stored token may have moved while this one was refused (a login,
427
+ // or a 0.6.2 process outside this lock). One more try with it.
428
+ const current = await this.loadToken(config.clientId).catch(() => null);
429
+ if (current && current !== refresh && deadline - Date.now() >= Math.min(MIN_ATTEMPT_MS, this.timeoutMs)) {
430
+ refresh = current;
431
+ outcome = await this.#attempt(config, refresh, deadline, held);
432
+ }
433
+ }
434
+ const { kind, result, attempts } = outcome;
435
+ if (kind === "dead") {
436
+ // Clear only the token the server rejected, never one stored since.
437
+ const current = await this.loadToken(config.clientId).catch(() => null);
438
+ const cleared = current === refresh;
439
+ if (cleared) {
172
440
  await this.deleteToken(config.clientId);
173
- throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth refresh failed and the saved credential was cleared; run `nodii-dev login` again.", 2);
441
+ await this.deleteAccess(config.clientId);
174
442
  }
175
- await this.storeToken(config.clientId, result.body.refresh_token);
176
- this.#accessToken = result.body.access_token;
177
- this.#expiresAt = Date.now() + result.body.expires_in * 1000;
178
- return this.#accessToken;
179
- }).finally(() => { this.#refreshPromise = undefined; });
180
- return this.#refreshPromise;
443
+ // What the server said, so `doctor` can name expired, revoked or replaced.
444
+ const said = (key) => (typeof result.body[key] === "string" ? result.body[key] : undefined);
445
+ const grant = Object.fromEntries([["error", said("error")], ["description", said("error_description")]].filter(([, v]) => v !== undefined));
446
+ throw new CliError("OAUTH_LOGIN_REQUIRED", `nodii-auth no longer accepts this sign-in (invalid_grant: expired, revoked or replayed)${cleared ? " and the saved credential was cleared" : ""}; run \`nodii-dev login\` again.`, 2, { grant });
447
+ }
448
+ if (kind === "retry")
449
+ 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);
450
+ if (kind === "refused")
451
+ throw new CliError("OAUTH_REFRESH_FAILED", `nodii-auth refused the refresh (${describe(result)}); the saved credential was kept.`, 1);
452
+ const body = result.body;
453
+ for (let attempt = 1; ; attempt++) {
454
+ try { await this.storeToken(config.clientId, body.refresh_token); break; } catch (error) {
455
+ if (attempt >= 3) throw error;
456
+ await this.sleep(200 * attempt);
457
+ }
458
+ }
459
+ this.#accessToken = body.access_token;
460
+ this.#expiresAt = Date.now() + Number(body.expires_in ?? 0) * 1000;
461
+ await this.storeAccess(config.clientId, { token: this.#accessToken, expiresAt: this.#expiresAt });
462
+ return this.#accessToken;
181
463
  }
182
464
  async headers() {
183
465
  const config = await readConfig();
@@ -185,11 +467,16 @@ export class OAuthAuthProvider {
185
467
  }
186
468
  }
187
469
 
188
- export async function revokeOAuth(config, fetchImpl = fetch) {
189
- const token = await loadRefreshToken(config.clientId);
190
- try {
191
- await oauthPost(config.authUrl, "/oauth/revoke", { token, token_type_hint: "refresh_token", client_id: config.clientId }, fetchImpl);
192
- } finally {
193
- await deleteRefreshToken(config.clientId);
194
- }
470
+ export async function revokeOAuth(config, fetchImpl = fetch, lock = withRefreshLock) {
471
+ // Under the refresh lock, so a refresh in flight cannot store a new token
472
+ // after logout has cleared the old one.
473
+ await lock(async () => {
474
+ try {
475
+ const token = await loadRefreshToken(config.clientId);
476
+ await oauthPost(config.authUrl, "/oauth/revoke", { token, token_type_hint: "refresh_token", client_id: config.clientId }, fetchImpl);
477
+ } finally {
478
+ await deleteRefreshToken(config.clientId);
479
+ await deleteAccessToken(config.clientId);
480
+ }
481
+ }, { clientId: config.clientId });
195
482
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {