@nodii/dev 0.6.3 → 0.6.5
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 +75 -4
- package/dist/cli.js +51 -3
- package/dist/core.js +1 -1
- package/dist/doctor.js +235 -0
- package/dist/harness.js +60 -15
- package/dist/lanes.js +9 -3
- package/dist/oauth.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,9 @@ asks you to run `nodii-dev login`.
|
|
|
45
45
|
`nodii-dev` is only the Dev control CLI. The local-stack CLI is `./nodii-stack`,
|
|
46
46
|
run from the `nodii-local-stack` checkout (renamed per operator ruling G3-6,
|
|
47
47
|
with no alias under the old name). Typing one of its verbs here, such as
|
|
48
|
-
`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.
|
|
49
51
|
|
|
50
52
|
Every command accepts `--json`; without it, output is formatted for humans.
|
|
51
53
|
Dev failures print Dev's error code to stderr and exit non-zero.
|
|
@@ -57,6 +59,7 @@ nodii-dev login [--no-browser] [--scope KEYS]
|
|
|
57
59
|
nodii-dev logout
|
|
58
60
|
nodii-dev whoami # OAuth identity plus the registered agent
|
|
59
61
|
nodii-dev config show
|
|
62
|
+
nodii-dev doctor [--json] # preflight: everything a lane needs, read-only
|
|
60
63
|
|
|
61
64
|
nodii-dev api list [--tag TAG] [--json]
|
|
62
65
|
nodii-dev api OPERATION_ID [--param name=value ...] [--body @file|-|JSON]
|
|
@@ -75,6 +78,8 @@ nodii-dev issues create --service ID --title TEXT [--body TEXT|--body-file FILE]
|
|
|
75
78
|
nodii-dev issues update ISSUE [the same mutable fields]
|
|
76
79
|
nodii-dev issues claim ISSUE [--ttl MINUTES]
|
|
77
80
|
nodii-dev issues release ISSUE [--note TEXT]
|
|
81
|
+
nodii-dev issues transition ISSUE --to todo|in_progress|in_review|blocked|done|wont_do
|
|
82
|
+
[--reason TEXT] [--dev-check REF] [--json]
|
|
78
83
|
|
|
79
84
|
nodii-dev inbox list [--service ID] [--kind drift|request|question]
|
|
80
85
|
nodii-dev inbox show drift|request|question ID
|
|
@@ -191,7 +196,33 @@ nodii-dev docs parked [CODE] [--status pending|acknowledged]
|
|
|
191
196
|
|
|
192
197
|
nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run]
|
|
193
198
|
nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run]
|
|
194
|
-
nodii-dev harness
|
|
199
|
+
nodii-dev harness check [--json] # 0 current, 1 differs from Dev, 2 no harness
|
|
200
|
+
nodii-dev harness update --check # the same check
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Moving an issue: `issues transition`
|
|
204
|
+
|
|
205
|
+
`issues transition` moves an issue along Dev's status map (MW1-16) with
|
|
206
|
+
Dev's `transitionIssue` operation. It checks MW1-17 before sending anything,
|
|
207
|
+
and refuses with `USAGE` (exit 2) when a move lacks what it needs:
|
|
208
|
+
|
|
209
|
+
| `--to` | Needs |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `blocked` | `--reason` naming the blocking condition and who or what clears it (`cleared by …`, `waiting on …`, `until …`) |
|
|
212
|
+
| `wont_do` | `--reason` saying why |
|
|
213
|
+
| `done` | `--dev-check REF` citing the lane's dev-check evidence (MW3-36); a merge alone never makes an issue done |
|
|
214
|
+
|
|
215
|
+
`--reason` and `--dev-check` are optional for the other moves, and
|
|
216
|
+
`--dev-check` goes only with `done`. Dev keeps one note per transition, so
|
|
217
|
+
the CLI sends the reason, then `Dev check: REF` on its own line, as that
|
|
218
|
+
note. A move Dev refuses (for example `ISSUE_ILLEGAL_TRANSITION`, with the
|
|
219
|
+
allowed set in its details, or `CLAIM_HELD_BY_OTHER`) exits 1 with Dev's
|
|
220
|
+
error code unchanged (MW1-61).
|
|
221
|
+
|
|
222
|
+
```text
|
|
223
|
+
nodii-dev issues transition TKT-412 --to blocked \
|
|
224
|
+
--reason "edge route /tickets 404s on dev; cleared by the nodii-edge lane deploying it"
|
|
225
|
+
nodii-dev issues transition TKT-412 --to done --dev-check "lane:tkt-412 dev probe GET /tickets/412 200"
|
|
195
226
|
```
|
|
196
227
|
|
|
197
228
|
## AD documents
|
|
@@ -252,10 +283,50 @@ source documents moved, and each file created, updated or removed. It
|
|
|
252
283
|
removes only files, blocks and hook entries it wrote, and it refuses (writing
|
|
253
284
|
nothing) when one of its files was edited by hand.
|
|
254
285
|
|
|
286
|
+
`harness check` (MW5-4) compares the repository with what Dev would generate
|
|
287
|
+
now and writes nothing. `harness update --check` is the same check. It asks
|
|
288
|
+
Dev with the options install recorded, and exits:
|
|
289
|
+
|
|
290
|
+
- `0` when every file matches and the stamp names the methodology version Dev
|
|
291
|
+
serves: `Harness is current: REPO, methodology VERSION, N files match Dev.`
|
|
292
|
+
- `1` when anything differs. It prints one line per difference: the
|
|
293
|
+
methodology version, each source document that moved, each file that is
|
|
294
|
+
missing, differs from Dev's or is no longer generated, and each file edited
|
|
295
|
+
by hand since nodii-dev wrote it. `--json` gives the same as `current`,
|
|
296
|
+
`installed`, `available`, `sources`, `changes` and `conflicts`.
|
|
297
|
+
- `2` with `HARNESS_NOT_INSTALLED` when the repository has no harness. The
|
|
298
|
+
message lists the files `harness install` would write.
|
|
299
|
+
|
|
300
|
+
Inside a lane, a failed check is noted in the step, and the harness files
|
|
301
|
+
are left alone; the repository's orchestrator runs `harness update` in a
|
|
302
|
+
`chore` lane (MW5-8).
|
|
303
|
+
|
|
304
|
+
## Preflight: `doctor`
|
|
305
|
+
|
|
306
|
+
Run `nodii-dev doctor` from the lane's worktree, with the lane agent's config,
|
|
307
|
+
before `lane start`. It only reads, and prints one line per check: `OK`,
|
|
308
|
+
`MISSING` (with the command that fixes it) or `CANNOT CHECK` (with why). It
|
|
309
|
+
exits 0 only when every check is `OK`. `--json` returns the same checks as
|
|
310
|
+
`{ data: { ok, configPath, configSource, checks: [{ key, status, message, fix?, ... }] } }`.
|
|
311
|
+
|
|
312
|
+
| Check | What it reads |
|
|
313
|
+
| --- | --- |
|
|
314
|
+
| `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. |
|
|
315
|
+
| `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. |
|
|
316
|
+
| `agent` | The agent on this config (`agentId`), as Dev reads it back. |
|
|
317
|
+
| `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. |
|
|
318
|
+
| `machine` | The machine this agent is assigned to and its coordinator. No machine means `lane start` would fail with `MACHINE_REQUIRED`. |
|
|
319
|
+
| `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`. |
|
|
320
|
+
| `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. |
|
|
321
|
+
|
|
322
|
+
Doctor may refresh the access token, as any command does. The permission table
|
|
323
|
+
is kept by hand from Dev's route policy until Dev's OpenAPI names each
|
|
324
|
+
operation's permission (requested in Dev).
|
|
325
|
+
|
|
255
326
|
## Build lanes and repository setup
|
|
256
327
|
|
|
257
|
-
`lane start` creates and starts a lane. It infers the repository
|
|
258
|
-
worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
|
|
328
|
+
`lane start` creates and starts a lane. It infers the repository (by short
|
|
329
|
+
name, as Dev compares it), branch and worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
|
|
259
330
|
required by Dev. Lane keys are passed directly to every lane and Built
|
|
260
331
|
acknowledgement route. `next` reads the lane's generated
|
|
261
332
|
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,8 +55,11 @@ 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
|
|
61
|
+
issues transition ISSUE --to STATUS [--reason TEXT] [--dev-check REF]
|
|
62
|
+
# blocked and wont_do need --reason; done needs --dev-check
|
|
59
63
|
inbox list|show
|
|
60
64
|
drift file | request file
|
|
61
65
|
decisions list|get|search
|
|
@@ -67,7 +71,8 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
67
71
|
hook claude-figma-pre|claude-figma-post|print-settings
|
|
68
72
|
setup [--yes|--check] # dry-run by default
|
|
69
73
|
harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run]
|
|
70
|
-
harness
|
|
74
|
+
harness check # 0 current, 1 differs from Dev, 2 not installed
|
|
75
|
+
harness update --check # the same check
|
|
71
76
|
docs check|validate|upload <path...> --class ruling|conformance|editorial
|
|
72
77
|
[--base CODE=VERSION] [--decision FILE] [--conformance-ref REF]
|
|
73
78
|
[--meaning-unchanged REASON] [--wait]
|
|
@@ -101,7 +106,8 @@ Run a command with --help or see the package README for all flags.`;
|
|
|
101
106
|
|
|
102
107
|
// Verbs of the local-stack CLI, which shared this name until it became
|
|
103
108
|
// ./nodii-stack (operator G3-6, no alias). Named here only to point there.
|
|
104
|
-
|
|
109
|
+
// `doctor` is not one of them: `nodii-dev doctor` is this CLI's own preflight.
|
|
110
|
+
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
111
|
|
|
106
112
|
async function stdinText() {
|
|
107
113
|
let value = "";
|
|
@@ -221,9 +227,44 @@ async function issues(action, args, f, json) {
|
|
|
221
227
|
}),
|
|
222
228
|
json,
|
|
223
229
|
);
|
|
230
|
+
if (action === "transition")
|
|
231
|
+
return emit(
|
|
232
|
+
await devRequest("POST", `/api/v1/issues/${encode(ref)}/transition`, {
|
|
233
|
+
...transitionBody(f),
|
|
234
|
+
onBehalfOf: config.person,
|
|
235
|
+
}),
|
|
236
|
+
json,
|
|
237
|
+
(p) => `${p.data.key} ${p.data.status} — ${p.data.title}`,
|
|
238
|
+
);
|
|
224
239
|
throw new CliError("USAGE", `Unknown issues action: ${action}`, 2);
|
|
225
240
|
}
|
|
226
241
|
|
|
242
|
+
const ISSUE_STATUSES = ["todo", "in_progress", "in_review", "blocked", "done", "wont_do"];
|
|
243
|
+
// A blocked reason names who or what clears it: "cleared by", "unblocks", "waiting on", "until".
|
|
244
|
+
const CLEARED_BY = /\b(clear(s|ed)?|unblock(s|ed)?|wait(s|ing)? (on|for)|until)\b/i;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* transitionIssue's body ({ to, note }), with MW1-17 checked before anything
|
|
248
|
+
* is sent. Dev stores no separate reason or dev-check field: both go in the note.
|
|
249
|
+
*/
|
|
250
|
+
export function transitionBody(f) {
|
|
251
|
+
const to = requireFlag(f, "to");
|
|
252
|
+
if (!ISSUE_STATUSES.includes(to))
|
|
253
|
+
throw new CliError("USAGE", `--to must be one of ${ISSUE_STATUSES.join(", ")}`, 2);
|
|
254
|
+
const reason = typeof f.reason === "string" ? f.reason.trim() : "";
|
|
255
|
+
const devCheck = typeof f["dev-check"] === "string" ? f["dev-check"].trim() : "";
|
|
256
|
+
if (f["dev-check"] !== undefined && to !== "done")
|
|
257
|
+
throw new CliError("USAGE", "--dev-check goes with --to done", 2);
|
|
258
|
+
if (to === "blocked" && !CLEARED_BY.test(reason))
|
|
259
|
+
throw new CliError("USAGE", 'A move to blocked needs --reason naming the blocking condition and who or what clears it (MW1-17), e.g. --reason "waiting on PR #12 in nodii-edge; cleared by the edge lane merging it"', 2);
|
|
260
|
+
if (to === "wont_do" && !reason)
|
|
261
|
+
throw new CliError("USAGE", "A move to wont_do needs --reason saying why (MW1-17)", 2);
|
|
262
|
+
if (to === "done" && !devCheck)
|
|
263
|
+
throw new CliError("USAGE", "A move to done needs --dev-check REF citing the lane's dev-check evidence (MW1-17, MW3-36). A merge alone never makes an issue done.", 2);
|
|
264
|
+
const note = [reason, devCheck && `Dev check: ${devCheck}`].filter(Boolean).join("\n");
|
|
265
|
+
return { to, ...(note ? { note } : {}) };
|
|
266
|
+
}
|
|
267
|
+
|
|
227
268
|
export async function main(argv = process.argv.slice(2)) {
|
|
228
269
|
if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
|
|
229
270
|
return console.log(VERSION);
|
|
@@ -272,7 +313,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
272
313
|
}
|
|
273
314
|
const emitCommand = async (result) => emit(result.payload, json, result.human);
|
|
274
315
|
if (command === "setup") return emitCommand(await setupCommand(f));
|
|
275
|
-
if (command === "
|
|
316
|
+
if (command === "doctor") {
|
|
317
|
+
if (action) throw new CliError("USAGE", `doctor takes no arguments, got: ${[action, ...rest].join(" ")}`, 2);
|
|
318
|
+
const result = await doctorCommand();
|
|
319
|
+
emit(result.payload, json, result.human);
|
|
320
|
+
if (result.exitCode) process.exitCode = result.exitCode;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (command === "harness" && ["install", "update", "check"].includes(action)) {
|
|
276
324
|
// `--include-drafts` is a switch: `--include-drafts false` would leave `false` here and turn drafts on.
|
|
277
325
|
if (rest.length) throw new CliError("USAGE", `harness ${action} takes no arguments, got: ${rest.join(" ")}. Write a flag value as --include-drafts=false.`, 2);
|
|
278
326
|
return emitCommand(await harnessCommand(action, f));
|
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.5";
|
|
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|transition" },
|
|
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/harness.js
CHANGED
|
@@ -290,33 +290,78 @@ export async function repoContext(cwd = process.cwd()) {
|
|
|
290
290
|
const flagList = (value) => (value === undefined || value === true ? [] : [value].flat().flatMap((x) => String(x).split(",")).filter(Boolean));
|
|
291
291
|
const changeLine = (c) => `${{ create: "+", remove: "-" }[c.action] ?? "~"} ${c.path}${["create", "update", "remove"].includes(c.action) ? "" : ` (${c.action})`}`;
|
|
292
292
|
|
|
293
|
+
// What each planned change means for a check: how the file on disk differs from Dev's.
|
|
294
|
+
const DIFFERENCE = {
|
|
295
|
+
create: "missing; Dev would create it",
|
|
296
|
+
update: "differs from Dev's",
|
|
297
|
+
"add block": "has no nodii-dev block",
|
|
298
|
+
merge: "hooks differ from Dev's",
|
|
299
|
+
remove: "Dev no longer generates it",
|
|
300
|
+
"remove block": "Dev no longer generates its block",
|
|
301
|
+
unmerge: "has hooks Dev no longer generates",
|
|
302
|
+
};
|
|
303
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `harness check` (and its alias `harness update --check`): compare the files
|
|
307
|
+
* on disk, and the methodology stamp, with what Dev would generate now (MW5-4,
|
|
308
|
+
* MW5-6). Writes nothing. Exit 0 when current, 1 when anything differs, 2
|
|
309
|
+
* when there is no harness.
|
|
310
|
+
*/
|
|
311
|
+
async function checkCommand(root, repo, state, fetchHarness) {
|
|
312
|
+
if (!state) {
|
|
313
|
+
let would;
|
|
314
|
+
try {
|
|
315
|
+
const plan = await planHarness(root, (await fetchHarness()).data, { install: true });
|
|
316
|
+
const paths = plan.changes.map((c) => c.path);
|
|
317
|
+
const taken = plan.conflicts.map((c) => c.path);
|
|
318
|
+
would = ` Dev would write ${plural(paths.length, "file")}: ${paths.join(", ")}.${taken.length ? ` ${plural(taken.length, "file")} ${taken.length === 1 ? "already exists and needs" : "already exist and need"} --adopt: ${taken.join(", ")}.` : ""}`;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
would = ` Dev cannot say what install would write (${error?.code ?? "ERROR"}: ${error?.message ?? error}).`;
|
|
321
|
+
}
|
|
322
|
+
throw new CliError("HARNESS_NOT_INSTALLED", `No nodii-dev harness here.${would} Run \`nodii-dev harness install\` first.`, 2);
|
|
323
|
+
}
|
|
324
|
+
// Compare the files Dev would write now, not only the version: the
|
|
325
|
+
// CLAUDE.md block also changes when the service's domain becomes AD.
|
|
326
|
+
const harness = (await fetchHarness()).data;
|
|
327
|
+
const plan = await planHarness(root, harness, { install: false });
|
|
328
|
+
const moved = sourceChanges(state.sources, harness.sources);
|
|
329
|
+
const versionMoved = harness.methodologyVersion !== state.methodologyVersion;
|
|
330
|
+
const behind = versionMoved || moved.length > 0 || plan.changes.length > 0 || plan.conflicts.length > 0;
|
|
331
|
+
const payload = { data: { repo, current: !behind, installed: state.methodologyVersion, available: harness.methodologyVersion, sources: moved, changes: plan.changes.map(({ path, action: a, mode }) => ({ path, action: a, mode })), conflicts: plan.conflicts } };
|
|
332
|
+
const owned = Object.keys(state.files ?? {}).length;
|
|
333
|
+
if (!behind) return { payload, human: `Harness is current: ${repo}, methodology ${state.methodologyVersion}, ${plural(owned, "file")} ${owned === 1 ? "matches" : "match"} Dev.` };
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
return {
|
|
336
|
+
payload,
|
|
337
|
+
human: [
|
|
338
|
+
`Harness differs from what Dev would generate for ${repo}:`,
|
|
339
|
+
...(versionMoved ? [` methodology: ${state.methodologyVersion} installed, ${harness.methodologyVersion} available`] : []),
|
|
340
|
+
...moved.map((x) => ` source ${x}`),
|
|
341
|
+
...plan.changes.map((c) => ` ${c.path}: ${DIFFERENCE[c.action] ?? c.action}`),
|
|
342
|
+
...plan.conflicts.map((c) => ` ${c.path}: ${c.reason}`),
|
|
343
|
+
"Fix with `nodii-dev harness update` (inside a lane: note this in the step and leave the harness files alone; MW5-8).",
|
|
344
|
+
].join("\n"),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
293
348
|
export async function harnessCommand(action, flags, { request = devRequest, context = repoContext } = {}) {
|
|
294
|
-
if (!["install", "update"].includes(action)) throw new CliError("USAGE", "harness install|update [--repo NAME] [--service ID] [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--check]", 2);
|
|
349
|
+
if (!["install", "update", "check"].includes(action)) throw new CliError("USAGE", "harness install|update|check [--repo NAME] [--service ID] [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--check]", 2);
|
|
295
350
|
if (flags.fleet || flags.all) throw new CliError("USAGE", "The harness is installed one service at a time, in that service's repository. There is no fleet mode.", 2);
|
|
296
351
|
if (flags.root === true || flags.repo === true) throw new CliError("USAGE", "--root and --repo need a value.", 2);
|
|
297
352
|
const ctx = flags.root ? { root: flags.root, repo: flags.repo } : await context();
|
|
298
353
|
const repo = flags.repo && flags.repo !== true ? String(flags.repo) : ctx.repo;
|
|
299
354
|
if (!repo) throw new CliError("USAGE", "Pass --repo NAME.", 2);
|
|
300
355
|
const state = await readState(ctx.root);
|
|
301
|
-
// Update
|
|
302
|
-
const saved = action === "
|
|
356
|
+
// Update and check ask Dev for what install asked for, unless told otherwise.
|
|
357
|
+
const saved = action === "install" ? {} : (state?.request ?? {});
|
|
303
358
|
const serviceId = flags.service && flags.service !== true ? String(flags.service) : (saved.serviceId ?? undefined);
|
|
304
359
|
const includeDrafts = flags["include-drafts"] !== undefined ? flags["include-drafts"] !== "false" : Boolean(saved.includeDrafts);
|
|
305
360
|
const params = { repo, serviceId, includeDrafts: includeDrafts ? "true" : undefined };
|
|
306
361
|
|
|
307
362
|
if (action === "install" && flags.check) throw new CliError("USAGE", "--check goes with `harness update`; to preview an install use --dry-run.", 2);
|
|
308
|
-
if (action === "
|
|
309
|
-
|
|
310
|
-
// Compare the files Dev would write now, not only the version: the
|
|
311
|
-
// CLAUDE.md block also changes when the service's domain becomes AD.
|
|
312
|
-
const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
|
|
313
|
-
const plan = await planHarness(ctx.root, harness, { install: false });
|
|
314
|
-
const moved = sourceChanges(state.sources, harness.sources);
|
|
315
|
-
const behind = harness.methodologyVersion !== state.methodologyVersion || plan.changes.length > 0 || plan.conflicts.length > 0;
|
|
316
|
-
const payload = { data: { repo, current: !behind, installed: state.methodologyVersion, available: harness.methodologyVersion, sources: moved, changes: plan.changes.map(({ path, action: a, mode }) => ({ path, action: a, mode })), conflicts: plan.conflicts } };
|
|
317
|
-
if (behind) process.exitCode = 1;
|
|
318
|
-
return { payload, human: behind ? [`Harness is behind: ${state.methodologyVersion} → ${harness.methodologyVersion}`, ...moved.map((x) => ` source ${x}`), ...plan.changes.map(changeLine), ...plan.conflicts.map((c) => ` conflict ${c.path}: ${c.reason}`), "Run `nodii-dev harness update`."].join("\n") : `Harness is current (${state.methodologyVersion}).` };
|
|
319
|
-
}
|
|
363
|
+
if (action === "check" && (flags["dry-run"] || flags.adopt)) throw new CliError("USAGE", "harness check writes nothing; --dry-run and --adopt go with install and update.", 2);
|
|
364
|
+
if (action === "check" || (action === "update" && flags.check)) return checkCommand(ctx.root, repo, state, () => request("GET", `/api/v1/harness${query(params)}`));
|
|
320
365
|
|
|
321
366
|
const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
|
|
322
367
|
const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts } });
|
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
|
@@ -440,7 +440,10 @@ export class OAuthAuthProvider {
|
|
|
440
440
|
await this.deleteToken(config.clientId);
|
|
441
441
|
await this.deleteAccess(config.clientId);
|
|
442
442
|
}
|
|
443
|
-
|
|
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 });
|
|
444
447
|
}
|
|
445
448
|
if (kind === "retry")
|
|
446
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);
|