@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 +37 -6
- package/dist/cli.js +16 -3
- package/dist/core.js +1 -1
- package/dist/doctor.js +235 -0
- package/dist/lanes.js +9 -3
- package/dist/oauth.js +337 -50
- package/package.json +1 -1
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.
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
27
|
-
repo = remote.replace(/\.git$/, "").replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "");
|
|
33
|
+
repo = repoName(await git("remote", "get-url", "origin"));
|
|
28
34
|
} catch {
|
|
29
|
-
repo = worktree
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
48
|
+
async function keychainStore(name, secret, os) {
|
|
47
49
|
if (os === "darwin") {
|
|
48
|
-
|
|
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",
|
|
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
|
-
|
|
63
|
-
if (
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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 ??=
|
|
391
|
+
this.#refreshPromise ??= (async () => {
|
|
168
392
|
const config = await readConfig();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
441
|
+
await this.deleteAccess(config.clientId);
|
|
174
442
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
}
|