@expo/code-review-cli 0.4.0 → 0.5.1
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 +139 -58
- package/build/cli.js +5 -0
- package/build/commands/ci.js +10 -6
- package/build/commands/doctor.js +82 -11
- package/build/commands/setup-auth.js +200 -0
- 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 +5 -4
- 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
|
@@ -26,7 +26,12 @@ flowchart TD
|
|
|
26
26
|
## Usage
|
|
27
27
|
|
|
28
28
|
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
|
|
29
|
-
binary once installed).
|
|
29
|
+
binary once installed). On a repo that already has `.expo-code-review/` set up,
|
|
30
|
+
getting model credentials for local runs is one command:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx @expo/code-review-cli setup-auth
|
|
34
|
+
```
|
|
30
35
|
|
|
31
36
|
Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
|
|
32
37
|
Everything else the reviewer needs (including the `opencode` runtime) ships with the
|
|
@@ -34,28 +39,30 @@ package.
|
|
|
34
39
|
|
|
35
40
|
### First-time setup
|
|
36
41
|
|
|
37
|
-
Scaffold, add credentials, verify.
|
|
38
|
-
|
|
39
42
|
```bash
|
|
40
|
-
# Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
|
43
|
+
# 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
|
41
44
|
npx @expo/code-review-cli init
|
|
45
|
+
# 2. Get model credentials — guided; prints the export lines for your shell config
|
|
46
|
+
npx @expo/code-review-cli setup-auth
|
|
47
|
+
# 3. Verify env, config, and credentials
|
|
48
|
+
npx @expo/code-review-cli doctor
|
|
42
49
|
```
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
`setup-auth` reads the repo's config and walks through each credential it needs:
|
|
52
|
+
an OpenAI **API key** (the scaffolded default — it prints where to create the key
|
|
53
|
+
and the exact restricted permissions to grant), and/or a **ChatGPT/Codex
|
|
54
|
+
subscription** sign-in (it runs OpenCode's browser login and extracts the token
|
|
55
|
+
for you). `doctor` offers to run it whenever a credential is missing.
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# Export it under the env var your config.jsonc's auth.tokenEnv names
|
|
52
|
-
export ANTHROPIC_OAUTH_API_KEY=sk-ant-oat...
|
|
53
|
-
# Check env, config, and credentials
|
|
54
|
-
npx @expo/code-review-cli doctor
|
|
55
|
-
```
|
|
57
|
+
In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
|
|
58
|
+
`CODEX_OAUTH_REFRESH_TOKEN` for the mixed setup) — the scaffolded workflow
|
|
59
|
+
forwards them.
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
|
62
|
+
production setup pairs the subscription (runs the default models at no marginal
|
|
63
|
+
cost) with the usage-based key (covers only the pro-tier models the subscription
|
|
64
|
+
excludes) — see [the mixed setup](#other-providers) below. Prefer
|
|
65
|
+
**Anthropic/Claude** or another provider? Same section.
|
|
59
66
|
|
|
60
67
|
### Reviewing (already configured)
|
|
61
68
|
|
|
@@ -103,6 +110,7 @@ is a ready example to adapt.
|
|
|
103
110
|
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
|
104
111
|
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
|
|
105
112
|
| `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
|
|
113
|
+
| `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT sign-in and/or API keys), printing the `export` lines for your shell config. |
|
|
106
114
|
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
107
115
|
| `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
|
|
108
116
|
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
@@ -142,7 +150,7 @@ your-monorepo/
|
|
|
142
150
|
// Central guardrails every scope inherits and CANNOT override.
|
|
143
151
|
"defaults": {
|
|
144
152
|
// The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
|
|
145
|
-
"auth": { "mode": "
|
|
153
|
+
"auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
|
|
146
154
|
"enforceAgents": ["security"], // always runs on every scope, roster or not
|
|
147
155
|
"commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
|
|
148
156
|
},
|
|
@@ -160,7 +168,7 @@ your-monorepo/
|
|
|
160
168
|
// server/www/.expo-code-review/config.jsonc (the www team owns this)
|
|
161
169
|
{
|
|
162
170
|
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
|
|
163
|
-
"model": "
|
|
171
|
+
"model": "openai/gpt-5.5",
|
|
164
172
|
"policy": { "includeSuggestions": false },
|
|
165
173
|
"noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
|
|
166
174
|
// shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
|
|
@@ -185,7 +193,7 @@ your-monorepo/
|
|
|
185
193
|
the override swaps the root config/manifest against the *real* scope tree, it
|
|
186
194
|
does not relocate the scopes themselves.**
|
|
187
195
|
- **Passes budget** — `defaults`-level `budget` bounds total review time:
|
|
188
|
-
`totalPassesMinutes` (default
|
|
196
|
+
`totalPassesMinutes` (default 55) is split across active scopes (which run
|
|
189
197
|
sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
|
|
190
198
|
single scope still gets a workable window. When enough scopes are active that the
|
|
191
199
|
floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
|
|
@@ -249,7 +257,7 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
|
|
|
249
257
|
suppressed.
|
|
250
258
|
|
|
251
259
|
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
|
|
252
|
-
and applies
|
|
260
|
+
and applies the provider's prompt caching automatically.
|
|
253
261
|
|
|
254
262
|
</details>
|
|
255
263
|
|
|
@@ -269,16 +277,17 @@ cache, in three places:
|
|
|
269
277
|
a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
|
|
270
278
|
per-agent findings, coverage notes, and what the verifier dropped.
|
|
271
279
|
|
|
272
|
-
**How the caching works.**
|
|
273
|
-
provider caches the rendered prompt up to a
|
|
280
|
+
**How the caching works.** Provider prompt caching is a *prefix match*: the
|
|
281
|
+
provider caches the rendered prompt up to a point, and any byte change anywhere
|
|
274
282
|
in that prefix invalidates everything after it. The reviewer is laid out so the
|
|
275
283
|
prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
|
|
276
284
|
byte-identical for every chunk an agent reviews, while the volatile parts (the
|
|
277
285
|
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
|
|
286
|
+
never touch it. OpenAI caches automatically (no write premium; cached input is
|
|
287
|
+
billed at a steep discount and shows up as `cache read`); Anthropic charges a
|
|
288
|
+
small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
|
|
289
|
+
it. Entries live minutes, refreshed on use — comfortably covering a run's
|
|
290
|
+
concurrent calls.
|
|
282
291
|
|
|
283
292
|
**Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
|
|
284
293
|
share of prompt tokens served from cache instead of being reprocessed at full
|
|
@@ -317,7 +326,7 @@ show writes (there is nothing to re-read within the run).
|
|
|
317
326
|
---
|
|
318
327
|
description: One line the router uses to decide relevance.
|
|
319
328
|
alwaysRun: true # run even when the router would skip this agent
|
|
320
|
-
model:
|
|
329
|
+
model: openai/gpt-5.5-pro # override the default model
|
|
321
330
|
temperature: 0.1
|
|
322
331
|
---
|
|
323
332
|
|
|
@@ -326,14 +335,14 @@ temperature: 0.1
|
|
|
326
335
|
|
|
327
336
|
For a real-world example, see eas-cli's
|
|
328
337
|
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
329
|
-
— correctness/security/consistency agents,
|
|
330
|
-
per-repo `noise.additionalIgnores`.
|
|
338
|
+
— correctness/security/consistency agents, a stronger model for security + the
|
|
339
|
+
coordinator, and per-repo `noise.additionalIgnores`.
|
|
331
340
|
|
|
332
341
|
`config.jsonc` (JSONC — comments + trailing commas supported):
|
|
333
342
|
|
|
334
343
|
```jsonc
|
|
335
344
|
{
|
|
336
|
-
"model": "
|
|
345
|
+
"model": "openai/gpt-5.5", // default model for the specialists
|
|
337
346
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
338
347
|
"chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
|
|
339
348
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
@@ -342,8 +351,8 @@ per-repo `noise.additionalIgnores`.
|
|
|
342
351
|
"skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
|
|
343
352
|
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
344
353
|
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
345
|
-
"auth": { "mode": "
|
|
346
|
-
"tokenEnv": "
|
|
354
|
+
"auth": { "mode": "api-key", "provider": "openai",
|
|
355
|
+
"tokenEnv": "OPENAI_API_KEY" }
|
|
347
356
|
}
|
|
348
357
|
```
|
|
349
358
|
|
|
@@ -357,14 +366,19 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
|
|
|
357
366
|
setup, and a developer can override everything locally.
|
|
358
367
|
|
|
359
368
|
- **Specialist agents** (correctness/security/consistency) benefit from a
|
|
360
|
-
reasoning-tier model —
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
(set in `security.md` frontmatter), the rest on
|
|
369
|
+
reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
|
|
370
|
+
(the scaffolded default). The **pro tier** finds more but is slower and more
|
|
371
|
+
expensive, so scope it to the highest-stakes agent: **security runs on
|
|
372
|
+
`openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
|
|
364
373
|
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
|
|
365
374
|
strong model; set it in `coordinator.md` frontmatter.
|
|
366
375
|
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
367
|
-
is the most direct lever (a real recall tradeoff —
|
|
376
|
+
(e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
|
|
377
|
+
measure it).
|
|
378
|
+
- **Every run logs which model actually answered each pass** — in the job log
|
|
379
|
+
(`Models used — …`), the Actions step summary table, and the run log's
|
|
380
|
+
`agentModels` — and warns loudly if a pass ran on a different model than
|
|
381
|
+
configured, so a provider-side substitution can never pass unnoticed.
|
|
368
382
|
|
|
369
383
|
There is no automatic cross-provider "equivalent" fallback — that would silently
|
|
370
384
|
change which model reviewed your code. Use an explicit override instead.
|
|
@@ -374,16 +388,32 @@ change which model reviewed your code. Use an explicit override instead.
|
|
|
374
388
|
<details>
|
|
375
389
|
<summary><b>Reliability</b> — never hangs, never silently drops work</summary>
|
|
376
390
|
|
|
377
|
-
- **Per-task time caps** — chunk passes 15 min;
|
|
378
|
-
|
|
379
|
-
|
|
391
|
+
- **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
|
|
392
|
+
budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
|
|
393
|
+
CI job's `timeout-minutes` (90).
|
|
394
|
+
- **The cross-file pass is elastic** — it gets whatever is left of the passes budget
|
|
395
|
+
rather than a fixed cap, because it's the one pass whose scope can't be traded for
|
|
396
|
+
convergence: halving its file set deletes exactly the coverage it exists for. Chunk
|
|
397
|
+
passes run alongside it under their own caps, so a long cross-file pass doesn't
|
|
398
|
+
starve them.
|
|
380
399
|
- **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
|
|
381
400
|
finishing is *wandering*, not converging; hitting the cap trips the soft landing.
|
|
401
|
+
The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
|
|
402
|
+
tool calls go to *tracing*, not fetching).
|
|
403
|
+
- **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
|
|
404
|
+
no streamed text or reasoning, no token growth) has a wedged model request, not a
|
|
405
|
+
hard problem. After 4 min of silence it's abandoned and retried once from a clean
|
|
406
|
+
session, inside the same budget — instead of spending the whole cap on a dead
|
|
407
|
+
request. Progress lines say how long a reply has been silent, so this is legible in
|
|
408
|
+
the CI log.
|
|
382
409
|
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
383
410
|
is asked to return the findings it already has, rather than discarding its work.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
411
|
+
Tools are disabled for that request, so the salvage step can't resume investigating
|
|
412
|
+
instead of answering.
|
|
413
|
+
- **Subdivide-on-timeout** — a reviewer pass that times out with nothing to show has
|
|
414
|
+
its chunk split in half and the halves re-reviewed (recursively, down to a single
|
|
415
|
+
file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
|
|
416
|
+
skips straight to the fallback, which still sees the whole diff). Only a genuinely
|
|
387
417
|
un-reducible pass reports a coverage gap — and it is always reported, never silent.
|
|
388
418
|
- **Parse failures are retried** (same session, then once in a bounded fresh
|
|
389
419
|
session) — separate from the timeout path.
|
|
@@ -448,23 +478,74 @@ visible even in CI (where the run log is ephemeral).
|
|
|
448
478
|
<details>
|
|
449
479
|
<summary><b>Other providers & auth modes</b></summary>
|
|
450
480
|
|
|
451
|
-
The recommended setup is
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
- **
|
|
455
|
-
|
|
456
|
-
the
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
481
|
+
The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
|
|
482
|
+
set in `config.auth` (credentials come from OpenCode):
|
|
483
|
+
|
|
484
|
+
- **ChatGPT/Codex subscription (OAuth) + usage-based API key — the recommended
|
|
485
|
+
mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
|
|
486
|
+
ships the plugin for it — so the reviewer runs its default models on the
|
|
487
|
+
subscription (zero marginal cost) and reserves the metered key for pro-tier
|
|
488
|
+
models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
|
|
489
|
+
Use the per-provider map form:
|
|
490
|
+
|
|
491
|
+
```jsonc
|
|
492
|
+
"auth": { "providers": {
|
|
493
|
+
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
|
494
|
+
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
495
|
+
} }
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`openai-api` is an alias the reviewer synthesizes in the OpenCode config
|
|
499
|
+
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
|
|
500
|
+
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
|
|
501
|
+
|
|
502
|
+
- **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
|
|
503
|
+
ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
|
|
504
|
+
access tokens are short-lived, so the refresh token is the durable secret and
|
|
505
|
+
OpenCode mints access tokens on demand. Refresh-token reuse across runs is
|
|
506
|
+
verified, so a static CI secret works.
|
|
507
|
+
- **The API key needs exactly two permissions** — a *Restricted* key with
|
|
508
|
+
*Model capabilities*: **Responses → Request** and **Chat completions →
|
|
509
|
+
Request**; everything else (including *List models*) stays None. Create it
|
|
510
|
+
in a dedicated, budget-capped project. (`ecr setup-auth` prints these
|
|
511
|
+
instructions too.)
|
|
512
|
+
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
|
513
|
+
comma-separated set of both env names
|
|
514
|
+
(`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
|
515
|
+
workflow.
|
|
516
|
+
- **Auditability**: every pass logs which provider/model answered it (job log,
|
|
517
|
+
step summary, run log), so the subscription/API split is visible per run.
|
|
518
|
+
One caveat: OpenCode can't price alias models (they're config-declared), so
|
|
519
|
+
pro passes report `$0` in the run log's cost column — token counts are
|
|
520
|
+
correct, and the OpenAI project dashboard is the source of truth for spend.
|
|
521
|
+
- **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
|
|
522
|
+
`tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
|
|
523
|
+
and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
|
|
524
|
+
Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
|
|
525
|
+
them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
|
|
526
|
+
API key works.
|
|
527
|
+
- **Another provider** — the current path is the `REVIEWER_MODEL`
|
|
461
528
|
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
|
-
|
|
529
|
+
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
|
|
530
|
+
and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
|
|
531
|
+
provider mixing beyond the alias mechanism above is on the
|
|
532
|
+
[roadmap](./ROADMAP.md).)*
|
|
466
533
|
|
|
467
534
|
There is no shared fallback key; if a run fails for lack of credentials, authenticate
|
|
468
535
|
a provider in OpenCode. `ecr doctor` diagnoses setup.
|
|
469
536
|
|
|
537
|
+
**Setup errors fail fast, with the fix in the message.** A bad credential or model id
|
|
538
|
+
would otherwise fail every pass identically — a run that spends its whole budget
|
|
539
|
+
rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
|
|
540
|
+
|
|
541
|
+
- **The credential's shape is checked.** OpenCode refuses a malformed credential by
|
|
542
|
+
dropping the provider entirely, which then surfaces as "model not found" for every
|
|
543
|
+
model, with nothing pointing at the credential. A truncated value, surrounding
|
|
544
|
+
whitespace, or a token that can't work for the configured `auth.mode` is rejected
|
|
545
|
+
by name.
|
|
546
|
+
- **Configured model ids are checked against the running server**, so a typo or an id
|
|
547
|
+
the provider doesn't have is reported once, up front, with the close matches.
|
|
548
|
+
- **`ecr doctor` reports the `opencode` version actually in use** and warns when a
|
|
549
|
+
different one is first on your `PATH` — runs use the version this package pins.
|
|
550
|
+
|
|
470
551
|
</details>
|
package/build/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dismissCommand } from "./commands/dismiss.js";
|
|
|
4
4
|
import { doctorCommand } from "./commands/doctor.js";
|
|
5
5
|
import { initCommand } from "./commands/init.js";
|
|
6
6
|
import { reviewCommand } from "./commands/review.js";
|
|
7
|
+
import { setupAuthCommand } from "./commands/setup-auth.js";
|
|
7
8
|
import { verifyConfigCommand } from "./commands/verify-config.js";
|
|
8
9
|
const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
|
|
9
10
|
|
|
@@ -13,6 +14,7 @@ Usage:
|
|
|
13
14
|
ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
|
|
14
15
|
ecr undismiss --pr <n> <id...> Restore a dismissed finding.
|
|
15
16
|
ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
|
|
17
|
+
ecr setup-auth [--yes] Walk through getting model credentials for local runs.
|
|
16
18
|
ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
|
|
17
19
|
ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
|
|
18
20
|
|
|
@@ -47,6 +49,9 @@ async function main() {
|
|
|
47
49
|
case "init":
|
|
48
50
|
await initCommand(rest);
|
|
49
51
|
break;
|
|
52
|
+
case "setup-auth":
|
|
53
|
+
await setupAuthCommand(rest);
|
|
54
|
+
break;
|
|
50
55
|
case "doctor":
|
|
51
56
|
await doctorCommand(rest);
|
|
52
57
|
break;
|
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,12 @@
|
|
|
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
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { setupAuthCommand } from "./setup-auth.js";
|
|
3
5
|
import { checkProviderAuth } from "../core/auth.js";
|
|
6
|
+
import { opencodeBinSource } from "../core/opencode.js";
|
|
4
7
|
import { git, onPath, repoRoot, run } from "../core/exec.js";
|
|
5
8
|
import { errorMessage } from "../core/util.js";
|
|
9
|
+
import path from "node:path";
|
|
6
10
|
const USAGE = `ecr doctor — check environment, config, and credentials
|
|
7
11
|
|
|
8
12
|
Usage:
|
|
@@ -16,6 +20,19 @@ ownership over tracked files, and comment-tag uniqueness.
|
|
|
16
20
|
Options:
|
|
17
21
|
--list-scopes Print the routing scope table (name, dir, paths, agents, tag)
|
|
18
22
|
`;
|
|
23
|
+
/**
|
|
24
|
+
* `opencode --version`, run from `binDir` if given (so the bundled CLI can be asked
|
|
25
|
+
* directly) or from PATH otherwise. Null when it can't be determined — a version we
|
|
26
|
+
* can't read is worth staying quiet about, not failing over.
|
|
27
|
+
*/
|
|
28
|
+
async function opencodeVersion(binDir) {
|
|
29
|
+
const command = binDir ? path.join(binDir, "opencode") : "opencode";
|
|
30
|
+
const { stdout, code } = await run(command, ["--version"], { check: false });
|
|
31
|
+
if (code !== 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return stdout.trim().split("\n")[0]?.trim() || null;
|
|
35
|
+
}
|
|
19
36
|
/** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
|
|
20
37
|
export async function doctorCommand(argv = []) {
|
|
21
38
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -47,10 +64,32 @@ export async function doctorCommand(argv = []) {
|
|
|
47
64
|
if (process.env.ECR_CONFIG_DIR) {
|
|
48
65
|
info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
|
|
49
66
|
}
|
|
50
|
-
|
|
67
|
+
// The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
|
|
68
|
+
// lookup. Report which one wins and whether it matches the version this package
|
|
69
|
+
// pins: a stale global install against a newer SDK rejects model ids the SDK
|
|
70
|
+
// considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
|
|
71
|
+
// failure that only reproduces on one machine. `startOpencode` prepends our own
|
|
72
|
+
// bin dir so the pinned one wins at runtime — this just makes the drift visible.
|
|
73
|
+
const bin = opencodeBinSource();
|
|
74
|
+
const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
|
|
51
75
|
line(opencodeInstalled, opencodeInstalled
|
|
52
|
-
? "opencode CLI
|
|
53
|
-
: "opencode CLI NOT
|
|
76
|
+
? "opencode CLI available"
|
|
77
|
+
: "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
|
|
78
|
+
if (opencodeInstalled) {
|
|
79
|
+
const pinnedVersion = bin.pinned ? await opencodeVersion(bin.dir) : null;
|
|
80
|
+
const pathVersion = await opencodeVersion(null);
|
|
81
|
+
if (pinnedVersion) {
|
|
82
|
+
line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
|
|
83
|
+
if (pathVersion && pathVersion !== pinnedVersion) {
|
|
84
|
+
warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
|
|
85
|
+
`but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (pathVersion) {
|
|
89
|
+
warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
|
|
90
|
+
`resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
54
93
|
line(await onPath("git"), "git found on PATH");
|
|
55
94
|
// `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
|
|
56
95
|
// informational (ℹ) rather than a hard failure for local `ecr review` users.
|
|
@@ -84,6 +123,34 @@ export async function doctorCommand(argv = []) {
|
|
|
84
123
|
line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
|
|
85
124
|
const readiness = checkProviderAuth(rootConfig);
|
|
86
125
|
line(readiness.ok, `auth: ${readiness.detail}`);
|
|
126
|
+
// A suspicious-but-not-provably-broken credential: worth saying, never a failure
|
|
127
|
+
// (the shape rules are heuristics — see checkOauthTokenShape).
|
|
128
|
+
if (readiness.warning) {
|
|
129
|
+
warn(`auth: ${readiness.warning}`);
|
|
130
|
+
}
|
|
131
|
+
// A missing credential has a guided fix — offer it right here when someone is
|
|
132
|
+
// at the terminal, rather than making them find the command in the README.
|
|
133
|
+
if (!readiness.ok) {
|
|
134
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
135
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
136
|
+
let runIt = false;
|
|
137
|
+
try {
|
|
138
|
+
const answer = (await rl.question(" Run `ecr setup-auth` to fix this now? [Y/n] "))
|
|
139
|
+
.trim()
|
|
140
|
+
.toLowerCase();
|
|
141
|
+
runIt = answer === "" || answer === "y" || answer === "yes";
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
rl.close();
|
|
145
|
+
}
|
|
146
|
+
if (runIt) {
|
|
147
|
+
await setupAuthCommand([]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
info("run `ecr setup-auth` for a guided credential setup");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
87
154
|
}
|
|
88
155
|
catch (error) {
|
|
89
156
|
line(false, `config invalid: ${errorMessage(error)}`);
|
|
@@ -139,14 +206,18 @@ export async function doctorCommand(argv = []) {
|
|
|
139
206
|
// auth singleton: exactly one honored source (defaults.auth or root config auth).
|
|
140
207
|
const auth = loadAuthFromRoot(rootConfig, manifest);
|
|
141
208
|
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
|
-
|
|
209
|
+
line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} ` +
|
|
210
|
+
`(${auth.map((entry) => `${entry.mode}/${entry.provider}`).join(", ")})`);
|
|
211
|
+
const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
|
|
212
|
+
if (expected) {
|
|
213
|
+
const mismatch = tokenEnvMismatch(auth, expected);
|
|
214
|
+
if (mismatch) {
|
|
215
|
+
line(false, `auth: ${mismatch}`);
|
|
147
216
|
}
|
|
148
|
-
|
|
149
|
-
|
|
217
|
+
}
|
|
218
|
+
for (const entry of auth) {
|
|
219
|
+
if (entry.tokenEnv) {
|
|
220
|
+
line(Boolean(process.env[entry.tokenEnv]), `auth token env ${entry.tokenEnv} (${entry.provider}) is ${process.env[entry.tokenEnv] ? "set" : "NOT set"}`);
|
|
150
221
|
}
|
|
151
222
|
}
|
|
152
223
|
// Owner-table dry run over tracked files (graft 4).
|