@expo/code-review-cli 0.4.0 → 0.5.0
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 +133 -50
- package/build/commands/ci.js +10 -6
- package/build/commands/doctor.js +57 -11
- package/build/commands/verify-config.js +65 -27
- package/build/config/load.js +50 -7
- package/build/config/schema.js +46 -16
- package/build/core/auth.js +209 -50
- package/build/core/coordinator.js +2 -2
- package/build/core/opencode.js +453 -53
- package/build/core/prompts.js +68 -7
- package/build/core/review.js +145 -32
- package/build/core/verify.js +4 -2
- package/package.json +3 -3
- package/templates/agents/security.md +4 -4
- package/templates/command.yml +11 -8
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +3 -3
- package/templates/routing.jsonc +1 -1
- package/templates/workflow.yml +13 -8
package/README.md
CHANGED
|
@@ -41,21 +41,33 @@ Scaffold, add credentials, verify.
|
|
|
41
41
|
npx @expo/code-review-cli init
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Then give it model credentials. **
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Then give it model credentials. **Default: an OpenAI API key** — the scaffolded
|
|
45
|
+
config reviews with GPT via `auth.mode "api-key"`.
|
|
46
|
+
|
|
47
|
+
Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
|
|
48
|
+
|
|
49
|
+
- Put it in a **dedicated project** (not "Default project") so you can set a
|
|
50
|
+
monthly budget + alert on it and see the reviewer's spend in isolation.
|
|
51
|
+
- Make it a **Restricted** key with exactly two permissions, both under *Model
|
|
52
|
+
capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
|
|
53
|
+
(/v1/chat/completions) → Request**. Everything else — including *List models* —
|
|
54
|
+
stays **None** (the reviewer resolves model ids from its own catalog and only
|
|
55
|
+
ever makes inference requests).
|
|
47
56
|
|
|
48
57
|
```bash
|
|
49
|
-
|
|
50
|
-
claude setup-token
|
|
51
|
-
# Export it under the env var your config.jsonc's auth.tokenEnv names
|
|
52
|
-
export ANTHROPIC_OAUTH_API_KEY=sk-ant-oat...
|
|
58
|
+
export OPENAI_API_KEY=sk-proj-...
|
|
53
59
|
# Check env, config, and credentials
|
|
54
60
|
npx @expo/code-review-cli doctor
|
|
55
61
|
```
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
In CI, store the same key as the `OPENAI_API_KEY` repo secret (the scaffolded
|
|
64
|
+
workflow forwards it).
|
|
65
|
+
|
|
66
|
+
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
|
67
|
+
production setup pairs the subscription (runs the default models at no marginal
|
|
68
|
+
cost) with the usage-based key (covers only the pro-tier models the subscription
|
|
69
|
+
excludes) — see [the mixed setup](#other-providers) below. Prefer
|
|
70
|
+
**Anthropic/Claude** or another provider? Same section.
|
|
59
71
|
|
|
60
72
|
### Reviewing (already configured)
|
|
61
73
|
|
|
@@ -142,7 +154,7 @@ your-monorepo/
|
|
|
142
154
|
// Central guardrails every scope inherits and CANNOT override.
|
|
143
155
|
"defaults": {
|
|
144
156
|
// The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
|
|
145
|
-
"auth": { "mode": "
|
|
157
|
+
"auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
|
|
146
158
|
"enforceAgents": ["security"], // always runs on every scope, roster or not
|
|
147
159
|
"commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
|
|
148
160
|
},
|
|
@@ -160,7 +172,7 @@ your-monorepo/
|
|
|
160
172
|
// server/www/.expo-code-review/config.jsonc (the www team owns this)
|
|
161
173
|
{
|
|
162
174
|
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
|
|
163
|
-
"model": "
|
|
175
|
+
"model": "openai/gpt-5.5",
|
|
164
176
|
"policy": { "includeSuggestions": false },
|
|
165
177
|
"noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
|
|
166
178
|
// shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
|
|
@@ -185,7 +197,7 @@ your-monorepo/
|
|
|
185
197
|
the override swaps the root config/manifest against the *real* scope tree, it
|
|
186
198
|
does not relocate the scopes themselves.**
|
|
187
199
|
- **Passes budget** — `defaults`-level `budget` bounds total review time:
|
|
188
|
-
`totalPassesMinutes` (default
|
|
200
|
+
`totalPassesMinutes` (default 55) is split across active scopes (which run
|
|
189
201
|
sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
|
|
190
202
|
single scope still gets a workable window. When enough scopes are active that the
|
|
191
203
|
floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
|
|
@@ -249,7 +261,7 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
|
|
|
249
261
|
suppressed.
|
|
250
262
|
|
|
251
263
|
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
|
|
252
|
-
and applies
|
|
264
|
+
and applies the provider's prompt caching automatically.
|
|
253
265
|
|
|
254
266
|
</details>
|
|
255
267
|
|
|
@@ -269,16 +281,17 @@ cache, in three places:
|
|
|
269
281
|
a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
|
|
270
282
|
per-agent findings, coverage notes, and what the verifier dropped.
|
|
271
283
|
|
|
272
|
-
**How the caching works.**
|
|
273
|
-
provider caches the rendered prompt up to a
|
|
284
|
+
**How the caching works.** Provider prompt caching is a *prefix match*: the
|
|
285
|
+
provider caches the rendered prompt up to a point, and any byte change anywhere
|
|
274
286
|
in that prefix invalidates everything after it. The reviewer is laid out so the
|
|
275
287
|
prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
|
|
276
288
|
byte-identical for every chunk an agent reviews, while the volatile parts (the
|
|
277
289
|
diff, file lists, PR metadata) travel in the user message *after* the prefix and
|
|
278
|
-
never touch it.
|
|
279
|
-
billed
|
|
280
|
-
|
|
281
|
-
refreshed on use
|
|
290
|
+
never touch it. OpenAI caches automatically (no write premium; cached input is
|
|
291
|
+
billed at a steep discount and shows up as `cache read`); Anthropic charges a
|
|
292
|
+
small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
|
|
293
|
+
it. Entries live minutes, refreshed on use — comfortably covering a run's
|
|
294
|
+
concurrent calls.
|
|
282
295
|
|
|
283
296
|
**Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
|
|
284
297
|
share of prompt tokens served from cache instead of being reprocessed at full
|
|
@@ -317,7 +330,7 @@ show writes (there is nothing to re-read within the run).
|
|
|
317
330
|
---
|
|
318
331
|
description: One line the router uses to decide relevance.
|
|
319
332
|
alwaysRun: true # run even when the router would skip this agent
|
|
320
|
-
model:
|
|
333
|
+
model: openai/gpt-5.5-pro # override the default model
|
|
321
334
|
temperature: 0.1
|
|
322
335
|
---
|
|
323
336
|
|
|
@@ -326,14 +339,14 @@ temperature: 0.1
|
|
|
326
339
|
|
|
327
340
|
For a real-world example, see eas-cli's
|
|
328
341
|
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
329
|
-
— correctness/security/consistency agents,
|
|
330
|
-
per-repo `noise.additionalIgnores`.
|
|
342
|
+
— correctness/security/consistency agents, a stronger model for security + the
|
|
343
|
+
coordinator, and per-repo `noise.additionalIgnores`.
|
|
331
344
|
|
|
332
345
|
`config.jsonc` (JSONC — comments + trailing commas supported):
|
|
333
346
|
|
|
334
347
|
```jsonc
|
|
335
348
|
{
|
|
336
|
-
"model": "
|
|
349
|
+
"model": "openai/gpt-5.5", // default model for the specialists
|
|
337
350
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
338
351
|
"chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
|
|
339
352
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
@@ -342,8 +355,8 @@ per-repo `noise.additionalIgnores`.
|
|
|
342
355
|
"skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
|
|
343
356
|
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
344
357
|
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
345
|
-
"auth": { "mode": "
|
|
346
|
-
"tokenEnv": "
|
|
358
|
+
"auth": { "mode": "api-key", "provider": "openai",
|
|
359
|
+
"tokenEnv": "OPENAI_API_KEY" }
|
|
347
360
|
}
|
|
348
361
|
```
|
|
349
362
|
|
|
@@ -357,14 +370,19 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
|
|
|
357
370
|
setup, and a developer can override everything locally.
|
|
358
371
|
|
|
359
372
|
- **Specialist agents** (correctness/security/consistency) benefit from a
|
|
360
|
-
reasoning-tier model —
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
(set in `security.md` frontmatter), the rest on
|
|
373
|
+
reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
|
|
374
|
+
(the scaffolded default). The **pro tier** finds more but is slower and more
|
|
375
|
+
expensive, so scope it to the highest-stakes agent: **security runs on
|
|
376
|
+
`openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
|
|
364
377
|
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
|
|
365
378
|
strong model; set it in `coordinator.md` frontmatter.
|
|
366
379
|
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
367
|
-
is the most direct lever (a real recall tradeoff —
|
|
380
|
+
(e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
|
|
381
|
+
measure it).
|
|
382
|
+
- **Every run logs which model actually answered each pass** — in the job log
|
|
383
|
+
(`Models used — …`), the Actions step summary table, and the run log's
|
|
384
|
+
`agentModels` — and warns loudly if a pass ran on a different model than
|
|
385
|
+
configured, so a provider-side substitution can never pass unnoticed.
|
|
368
386
|
|
|
369
387
|
There is no automatic cross-provider "equivalent" fallback — that would silently
|
|
370
388
|
change which model reviewed your code. Use an explicit override instead.
|
|
@@ -374,16 +392,32 @@ change which model reviewed your code. Use an explicit override instead.
|
|
|
374
392
|
<details>
|
|
375
393
|
<summary><b>Reliability</b> — never hangs, never silently drops work</summary>
|
|
376
394
|
|
|
377
|
-
- **Per-task time caps** — chunk passes 15 min;
|
|
378
|
-
|
|
379
|
-
|
|
395
|
+
- **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
|
|
396
|
+
budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
|
|
397
|
+
CI job's `timeout-minutes` (90).
|
|
398
|
+
- **The cross-file pass is elastic** — it gets whatever is left of the passes budget
|
|
399
|
+
rather than a fixed cap, because it's the one pass whose scope can't be traded for
|
|
400
|
+
convergence: halving its file set deletes exactly the coverage it exists for. Chunk
|
|
401
|
+
passes run alongside it under their own caps, so a long cross-file pass doesn't
|
|
402
|
+
starve them.
|
|
380
403
|
- **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
|
|
381
404
|
finishing is *wandering*, not converging; hitting the cap trips the soft landing.
|
|
405
|
+
The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
|
|
406
|
+
tool calls go to *tracing*, not fetching).
|
|
407
|
+
- **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
|
|
408
|
+
no streamed text or reasoning, no token growth) has a wedged model request, not a
|
|
409
|
+
hard problem. After 4 min of silence it's abandoned and retried once from a clean
|
|
410
|
+
session, inside the same budget — instead of spending the whole cap on a dead
|
|
411
|
+
request. Progress lines say how long a reply has been silent, so this is legible in
|
|
412
|
+
the CI log.
|
|
382
413
|
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
383
414
|
is asked to return the findings it already has, rather than discarding its work.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
415
|
+
Tools are disabled for that request, so the salvage step can't resume investigating
|
|
416
|
+
instead of answering.
|
|
417
|
+
- **Subdivide-on-timeout** — a reviewer pass that times out with nothing to show has
|
|
418
|
+
its chunk split in half and the halves re-reviewed (recursively, down to a single
|
|
419
|
+
file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
|
|
420
|
+
skips straight to the fallback, which still sees the whole diff). Only a genuinely
|
|
387
421
|
un-reducible pass reports a coverage gap — and it is always reported, never silent.
|
|
388
422
|
- **Parse failures are retried** (same session, then once in a bounded fresh
|
|
389
423
|
session) — separate from the timeout path.
|
|
@@ -448,23 +482,72 @@ visible even in CI (where the run log is ephemeral).
|
|
|
448
482
|
<details>
|
|
449
483
|
<summary><b>Other providers & auth modes</b></summary>
|
|
450
484
|
|
|
451
|
-
The recommended setup is
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
- **
|
|
455
|
-
|
|
456
|
-
the
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
485
|
+
The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
|
|
486
|
+
set in `config.auth` (credentials come from OpenCode):
|
|
487
|
+
|
|
488
|
+
- **ChatGPT/Codex subscription (OAuth) + usage-based API key — the recommended
|
|
489
|
+
mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
|
|
490
|
+
ships the plugin for it — so the reviewer runs its default models on the
|
|
491
|
+
subscription (zero marginal cost) and reserves the metered key for pro-tier
|
|
492
|
+
models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
|
|
493
|
+
Use the per-provider map form:
|
|
494
|
+
|
|
495
|
+
```jsonc
|
|
496
|
+
"auth": { "providers": {
|
|
497
|
+
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
|
498
|
+
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
499
|
+
} }
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`openai-api` is an alias the reviewer synthesizes in the OpenCode config
|
|
503
|
+
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
|
|
504
|
+
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
|
|
505
|
+
|
|
506
|
+
- **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
|
|
507
|
+
ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
|
|
508
|
+
access tokens are short-lived, so the refresh token is the durable secret and
|
|
509
|
+
OpenCode mints access tokens on demand. Refresh-token reuse across runs is
|
|
510
|
+
verified, so a static CI secret works.
|
|
511
|
+
- **The API key needs the same two permissions** as the default setup above
|
|
512
|
+
(Responses + Chat completions → Request; all else None), in a budget-capped
|
|
513
|
+
project.
|
|
514
|
+
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
|
515
|
+
comma-separated set of both env names
|
|
516
|
+
(`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
|
517
|
+
workflow.
|
|
518
|
+
- **Auditability**: every pass logs which provider/model answered it (job log,
|
|
519
|
+
step summary, run log), so the subscription/API split is visible per run.
|
|
520
|
+
One caveat: OpenCode can't price alias models (they're config-declared), so
|
|
521
|
+
pro passes report `$0` in the run log's cost column — token counts are
|
|
522
|
+
correct, and the OpenAI project dashboard is the source of truth for spend.
|
|
523
|
+
- **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
|
|
524
|
+
`tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
|
|
525
|
+
and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
|
|
526
|
+
Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
|
|
527
|
+
them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
|
|
528
|
+
API key works.
|
|
529
|
+
- **Another provider** — the current path is the `REVIEWER_MODEL`
|
|
461
530
|
env override: `opencode auth login` once (pick the provider), then run with
|
|
462
|
-
e.g. `REVIEWER_MODEL=
|
|
463
|
-
and uses your OpenCode login, so no `auth` block is needed. *(
|
|
464
|
-
|
|
465
|
-
|
|
531
|
+
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
|
|
532
|
+
and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
|
|
533
|
+
provider mixing beyond the alias mechanism above is on the
|
|
534
|
+
[roadmap](./ROADMAP.md).)*
|
|
466
535
|
|
|
467
536
|
There is no shared fallback key; if a run fails for lack of credentials, authenticate
|
|
468
537
|
a provider in OpenCode. `ecr doctor` diagnoses setup.
|
|
469
538
|
|
|
539
|
+
**Setup errors fail fast, with the fix in the message.** A bad credential or model id
|
|
540
|
+
would otherwise fail every pass identically — a run that spends its whole budget
|
|
541
|
+
rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
|
|
542
|
+
|
|
543
|
+
- **The credential's shape is checked.** OpenCode refuses a malformed credential by
|
|
544
|
+
dropping the provider entirely, which then surfaces as "model not found" for every
|
|
545
|
+
model, with nothing pointing at the credential. A truncated value, surrounding
|
|
546
|
+
whitespace, or a token that can't work for the configured `auth.mode` is rejected
|
|
547
|
+
by name.
|
|
548
|
+
- **Configured model ids are checked against the running server**, so a typo or an id
|
|
549
|
+
the provider doesn't have is reported once, up front, with the close matches.
|
|
550
|
+
- **`ecr doctor` reports the `opencode` version actually in use** and warns when a
|
|
551
|
+
different one is first on your `PATH` — runs use the version this package pins.
|
|
552
|
+
|
|
470
553
|
</details>
|
package/build/commands/ci.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig } from "../config/load.js";
|
|
2
|
+
import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
|
|
3
3
|
import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
4
4
|
import { repoRoot, run } from "../core/exec.js";
|
|
5
5
|
import { errorMessage } from "../core/util.js";
|
|
@@ -128,9 +128,12 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
|
|
|
128
128
|
// bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
|
|
129
129
|
// check compares the tokenEnv the loader actually honors.
|
|
130
130
|
const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
|
|
131
|
-
if (expectedTokenEnv
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
if (expectedTokenEnv) {
|
|
132
|
+
const mismatch = tokenEnvMismatch(config.auth, expectedTokenEnv);
|
|
133
|
+
if (mismatch) {
|
|
134
|
+
process.stderr.write(`CI reviewer: ${mismatch}; refusing to run.\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
134
137
|
}
|
|
135
138
|
// Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
|
|
136
139
|
// whether this PR should be reviewed at all (bypassed by a manual /review).
|
|
@@ -225,8 +228,9 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
|
|
|
225
228
|
const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
|
|
226
229
|
if (expectedTokenEnv) {
|
|
227
230
|
const honored = loadAuthFromRoot(rootConfig, manifest);
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
const mismatch = tokenEnvMismatch(honored, expectedTokenEnv);
|
|
232
|
+
if (mismatch) {
|
|
233
|
+
process.stderr.write(`CI reviewer: honored ${mismatch}; refusing to run.\n`);
|
|
230
234
|
return;
|
|
231
235
|
}
|
|
232
236
|
}
|
package/build/commands/doctor.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, } from "../config/load.js";
|
|
1
|
+
import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
|
|
2
2
|
import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
3
3
|
import { checkProviderAuth } from "../core/auth.js";
|
|
4
|
+
import { opencodeBinSource } from "../core/opencode.js";
|
|
4
5
|
import { git, onPath, repoRoot, run } from "../core/exec.js";
|
|
5
6
|
import { errorMessage } from "../core/util.js";
|
|
7
|
+
import path from "node:path";
|
|
6
8
|
const USAGE = `ecr doctor — check environment, config, and credentials
|
|
7
9
|
|
|
8
10
|
Usage:
|
|
@@ -16,6 +18,19 @@ ownership over tracked files, and comment-tag uniqueness.
|
|
|
16
18
|
Options:
|
|
17
19
|
--list-scopes Print the routing scope table (name, dir, paths, agents, tag)
|
|
18
20
|
`;
|
|
21
|
+
/**
|
|
22
|
+
* `opencode --version`, run from `binDir` if given (so the bundled CLI can be asked
|
|
23
|
+
* directly) or from PATH otherwise. Null when it can't be determined — a version we
|
|
24
|
+
* can't read is worth staying quiet about, not failing over.
|
|
25
|
+
*/
|
|
26
|
+
async function opencodeVersion(binDir) {
|
|
27
|
+
const command = binDir ? path.join(binDir, "opencode") : "opencode";
|
|
28
|
+
const { stdout, code } = await run(command, ["--version"], { check: false });
|
|
29
|
+
if (code !== 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return stdout.trim().split("\n")[0]?.trim() || null;
|
|
33
|
+
}
|
|
19
34
|
/** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
|
|
20
35
|
export async function doctorCommand(argv = []) {
|
|
21
36
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -47,10 +62,32 @@ export async function doctorCommand(argv = []) {
|
|
|
47
62
|
if (process.env.ECR_CONFIG_DIR) {
|
|
48
63
|
info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
|
|
49
64
|
}
|
|
50
|
-
|
|
65
|
+
// The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
|
|
66
|
+
// lookup. Report which one wins and whether it matches the version this package
|
|
67
|
+
// pins: a stale global install against a newer SDK rejects model ids the SDK
|
|
68
|
+
// considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
|
|
69
|
+
// failure that only reproduces on one machine. `startOpencode` prepends our own
|
|
70
|
+
// bin dir so the pinned one wins at runtime — this just makes the drift visible.
|
|
71
|
+
const bin = opencodeBinSource();
|
|
72
|
+
const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
|
|
51
73
|
line(opencodeInstalled, opencodeInstalled
|
|
52
|
-
? "opencode CLI
|
|
53
|
-
: "opencode CLI NOT
|
|
74
|
+
? "opencode CLI available"
|
|
75
|
+
: "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
|
|
76
|
+
if (opencodeInstalled) {
|
|
77
|
+
const pinnedVersion = bin.pinned ? await opencodeVersion(bin.dir) : null;
|
|
78
|
+
const pathVersion = await opencodeVersion(null);
|
|
79
|
+
if (pinnedVersion) {
|
|
80
|
+
line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
|
|
81
|
+
if (pathVersion && pathVersion !== pinnedVersion) {
|
|
82
|
+
warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
|
|
83
|
+
`but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (pathVersion) {
|
|
87
|
+
warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
|
|
88
|
+
`resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
54
91
|
line(await onPath("git"), "git found on PATH");
|
|
55
92
|
// `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
|
|
56
93
|
// informational (ℹ) rather than a hard failure for local `ecr review` users.
|
|
@@ -84,6 +121,11 @@ export async function doctorCommand(argv = []) {
|
|
|
84
121
|
line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
|
|
85
122
|
const readiness = checkProviderAuth(rootConfig);
|
|
86
123
|
line(readiness.ok, `auth: ${readiness.detail}`);
|
|
124
|
+
// A suspicious-but-not-provably-broken credential: worth saying, never a failure
|
|
125
|
+
// (the shape rules are heuristics — see checkOauthTokenShape).
|
|
126
|
+
if (readiness.warning) {
|
|
127
|
+
warn(`auth: ${readiness.warning}`);
|
|
128
|
+
}
|
|
87
129
|
}
|
|
88
130
|
catch (error) {
|
|
89
131
|
line(false, `config invalid: ${errorMessage(error)}`);
|
|
@@ -139,14 +181,18 @@ export async function doctorCommand(argv = []) {
|
|
|
139
181
|
// auth singleton: exactly one honored source (defaults.auth or root config auth).
|
|
140
182
|
const auth = loadAuthFromRoot(rootConfig, manifest);
|
|
141
183
|
const hasManifestAuth = Boolean(manifest.defaults.auth);
|
|
142
|
-
line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
184
|
+
line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} ` +
|
|
185
|
+
`(${auth.map((entry) => `${entry.mode}/${entry.provider}`).join(", ")})`);
|
|
186
|
+
const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
|
|
187
|
+
if (expected) {
|
|
188
|
+
const mismatch = tokenEnvMismatch(auth, expected);
|
|
189
|
+
if (mismatch) {
|
|
190
|
+
line(false, `auth: ${mismatch}`);
|
|
147
191
|
}
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
}
|
|
193
|
+
for (const entry of auth) {
|
|
194
|
+
if (entry.tokenEnv) {
|
|
195
|
+
line(Boolean(process.env[entry.tokenEnv]), `auth token env ${entry.tokenEnv} (${entry.provider}) is ${process.env[entry.tokenEnv] ? "set" : "NOT set"}`);
|
|
150
196
|
}
|
|
151
197
|
}
|
|
152
198
|
// Owner-table dry run over tracked files (graft 4).
|
|
@@ -14,15 +14,17 @@ The canonical pre-review guard (ships with the CLI). It sweeps EVERY
|
|
|
14
14
|
plain recursive walk (skipping node_modules/.git, so a staged-but-unreferenced
|
|
15
15
|
config can't hide from git's index), parses each with the real comment-aware JSONC
|
|
16
16
|
parser (never regex-scraping), and refuses to run (exit 1) when:
|
|
17
|
-
• auth.tokenEnv
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
• a tokenEnv (auth.tokenEnv, or any auth.providers.<id>.tokenEnv; routing.jsonc:
|
|
18
|
+
same under defaults.auth) appears in a non-root file, in more than one root
|
|
19
|
+
file, twice under the same name, or — with --expected / ECR_EXPECTED_TOKEN_ENV
|
|
20
|
+
set (comma-separated) — the declared set differs from the expected set;
|
|
20
21
|
• a non-root config declares auth, breakGlass, or commentTag (root-locked keys);
|
|
21
22
|
• any file fails to parse (fail-closed), reporting the parse error.
|
|
22
23
|
Exit 0 = safe to run the review.
|
|
23
24
|
|
|
24
25
|
Options:
|
|
25
|
-
--expected <
|
|
26
|
+
--expected <ENVS> Require the declared tokenEnv set to equal this
|
|
27
|
+
comma-separated set (else ECR_EXPECTED_TOKEN_ENV).
|
|
26
28
|
--json Emit {ok, findings:[{file, problem}]} on stdout.
|
|
27
29
|
`;
|
|
28
30
|
const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
|
|
@@ -63,22 +65,38 @@ function asObject(value) {
|
|
|
63
65
|
? value
|
|
64
66
|
: undefined;
|
|
65
67
|
}
|
|
68
|
+
/** Every tokenEnv an auth block names — legacy single, or one per providers entry. */
|
|
69
|
+
function collectTokenEnvs(auth) {
|
|
70
|
+
if (!auth) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const found = [];
|
|
74
|
+
if (typeof auth.tokenEnv === "string") {
|
|
75
|
+
found.push(auth.tokenEnv);
|
|
76
|
+
}
|
|
77
|
+
const providers = asObject(auth.providers);
|
|
78
|
+
for (const entry of Object.values(providers ?? {})) {
|
|
79
|
+
const tokenEnv = asObject(entry)?.tokenEnv;
|
|
80
|
+
if (typeof tokenEnv === "string") {
|
|
81
|
+
found.push(tokenEnv);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
66
86
|
/** Read the security-relevant declarations from a parsed config/routing object. */
|
|
67
87
|
function extractFacts(file, parsed) {
|
|
68
88
|
if (path.basename(file) === ROUTING_FILENAME) {
|
|
69
89
|
// routing.jsonc locks auth under defaults.auth (defaults.auth.tokenEnv).
|
|
70
90
|
const defaults = asObject(parsed.defaults);
|
|
71
|
-
const auth = asObject(defaults?.auth);
|
|
72
91
|
return {
|
|
73
|
-
|
|
92
|
+
tokenEnvs: collectTokenEnvs(asObject(defaults?.auth)),
|
|
74
93
|
declaresAuth: Boolean(defaults) && "auth" in defaults,
|
|
75
94
|
declaresBreakGlass: false, // routing.jsonc has no breakGlass concept
|
|
76
95
|
declaresCommentTag: Boolean(defaults) && "commentTag" in defaults,
|
|
77
96
|
};
|
|
78
97
|
}
|
|
79
|
-
const auth = asObject(parsed.auth);
|
|
80
98
|
return {
|
|
81
|
-
|
|
99
|
+
tokenEnvs: collectTokenEnvs(asObject(parsed.auth)),
|
|
82
100
|
declaresAuth: "auth" in parsed,
|
|
83
101
|
declaresBreakGlass: "breakGlass" in parsed,
|
|
84
102
|
declaresCommentTag: "commentTag" in parsed,
|
|
@@ -113,8 +131,8 @@ export async function verifyConfig(root, options = {}) {
|
|
|
113
131
|
continue;
|
|
114
132
|
}
|
|
115
133
|
const facts = extractFacts(file, object);
|
|
116
|
-
|
|
117
|
-
tokenEnvOccurrences.push({ file: rel(file), value
|
|
134
|
+
for (const value of facts.tokenEnvs) {
|
|
135
|
+
tokenEnvOccurrences.push({ file: rel(file), value, isRoot });
|
|
118
136
|
}
|
|
119
137
|
if (!isRoot) {
|
|
120
138
|
const locked = [];
|
|
@@ -135,38 +153,58 @@ export async function verifyConfig(root, options = {}) {
|
|
|
135
153
|
}
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
|
-
//
|
|
156
|
+
// tokenEnvs may only be declared in root-owned files…
|
|
139
157
|
for (const occurrence of tokenEnvOccurrences.filter((o) => !o.isRoot)) {
|
|
140
158
|
findings.push({
|
|
141
159
|
file: occurrence.file,
|
|
142
160
|
problem: `tokenEnv "${occurrence.value}" is declared outside the root config; only a root-owned config.jsonc/config.json or routing.jsonc may name the forwarded credential`,
|
|
143
161
|
});
|
|
144
162
|
}
|
|
145
|
-
|
|
163
|
+
// …and all in ONE root file (multiple entries in one auth block are fine —
|
|
164
|
+
// that's the multi-provider map — but split across files there is no single
|
|
165
|
+
// honored source and a stale/staged second file could smuggle a credential).
|
|
166
|
+
const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
|
|
167
|
+
const rootFiles = [...new Set(rootOccurrences.map((o) => o.file))];
|
|
168
|
+
if (rootFiles.length > 1) {
|
|
146
169
|
findings.push({
|
|
147
|
-
file:
|
|
148
|
-
problem: `tokenEnv is declared in ${
|
|
170
|
+
file: rootFiles.join(", "),
|
|
171
|
+
problem: `tokenEnv is declared in ${rootFiles.length} root files; all credential env names must live in ONE root-owned config`,
|
|
149
172
|
});
|
|
150
173
|
}
|
|
151
|
-
//
|
|
174
|
+
// Duplicate names within a file are a config bug worth failing on too: two auth
|
|
175
|
+
// entries forwarding the same env var means one of them is misconfigured.
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
for (const occurrence of rootOccurrences) {
|
|
178
|
+
if (seen.has(occurrence.value)) {
|
|
179
|
+
findings.push({
|
|
180
|
+
file: occurrence.file,
|
|
181
|
+
problem: `tokenEnv "${occurrence.value}" is declared more than once; each credential env name must appear exactly once`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
seen.add(occurrence.value);
|
|
185
|
+
}
|
|
186
|
+
// With an expectation set, the declared names must equal the expected SET
|
|
187
|
+
// exactly (comma-separated; order-insensitive). A missing name is as much a
|
|
188
|
+
// finding as an extra one — a PR must not add, drop, or repoint credentials.
|
|
152
189
|
const expected = options.expected;
|
|
153
190
|
if (expected) {
|
|
154
|
-
const
|
|
155
|
-
|
|
191
|
+
const expectedSet = expected
|
|
192
|
+
.split(",")
|
|
193
|
+
.map((name) => name.trim())
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.sort();
|
|
196
|
+
const declared = [...seen].sort();
|
|
197
|
+
if (declared.length === 0) {
|
|
156
198
|
findings.push({
|
|
157
199
|
file: path.join(CONFIG_DIRNAME, "config.jsonc"),
|
|
158
|
-
problem: `no tokenEnv found, but
|
|
200
|
+
problem: `no tokenEnv found, but expected "${expectedSet.join(", ")}" — the root-owned config must name exactly those credential env(s)`,
|
|
159
201
|
});
|
|
160
202
|
}
|
|
161
|
-
else {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
problem: `tokenEnv "${occurrence.value}" != expected "${expected}" — a PR must not repoint which secret is forwarded to the model provider`,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
}
|
|
203
|
+
else if (JSON.stringify(declared) !== JSON.stringify(expectedSet)) {
|
|
204
|
+
findings.push({
|
|
205
|
+
file: rootFiles.join(", ") || path.join(CONFIG_DIRNAME, "config.jsonc"),
|
|
206
|
+
problem: `declared tokenEnv set [${declared.join(", ")}] != expected [${expectedSet.join(", ")}] — a PR must not add, drop, or repoint which secrets are forwarded to model providers`,
|
|
207
|
+
});
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
210
|
return { ok: findings.length === 0, findings };
|