@expo/code-review-cli 0.3.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 +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/build/commands/review.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { loadReviewConfig } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
1
|
+
import { loadReviewConfig, loadScopeConfig } from "../config/load.js";
|
|
2
|
+
import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/routing.js";
|
|
3
|
+
import { repoRoot, resolveRepo } from "../core/exec.js";
|
|
4
|
+
import { errorMessage } from "../core/util.js";
|
|
5
|
+
import { runReview } from "../core/review.js";
|
|
6
|
+
import { LocalGitSource } from "../sources/local-git.js";
|
|
7
|
+
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
8
|
+
import { memoizeSource } from "../sources/source.js";
|
|
9
|
+
import { TerminalReporter } from "../reporters/terminal.js";
|
|
10
|
+
import { GitHubReporter } from "../reporters/github.js";
|
|
9
11
|
const USAGE = `ecr review — AI code review, printed to your terminal
|
|
10
12
|
|
|
11
13
|
Usage:
|
|
@@ -28,6 +30,10 @@ Options:
|
|
|
28
30
|
to publish.
|
|
29
31
|
--agents <a,b> run only these agents (comma-separated ids); default: all
|
|
30
32
|
--route let the router pick relevant agents from the diff
|
|
33
|
+
--scope <name> review only this routing scope (needs a routing.jsonc);
|
|
34
|
+
runs its config over just that scope's changed files
|
|
35
|
+
--config-dir <dir> load config from <dir> instead of .expo-code-review/
|
|
36
|
+
(also ECR_CONFIG_DIR); can't combine with --scope
|
|
31
37
|
--json emit machine-readable JSON on stdout
|
|
32
38
|
--no-fail always exit 0, even on request-changes
|
|
33
39
|
-h, --help show this help
|
|
@@ -39,7 +45,7 @@ on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
|
|
|
39
45
|
Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
|
|
40
46
|
`;
|
|
41
47
|
function requireValue(flag, value) {
|
|
42
|
-
if (value === undefined || value.startsWith(
|
|
48
|
+
if (value === undefined || value.startsWith("--")) {
|
|
43
49
|
throw new Error(`${flag} requires a value`);
|
|
44
50
|
}
|
|
45
51
|
return value;
|
|
@@ -56,16 +62,16 @@ function parseArgs(argv) {
|
|
|
56
62
|
for (let i = 0; i < argv.length; i++) {
|
|
57
63
|
const arg = argv[i];
|
|
58
64
|
switch (arg) {
|
|
59
|
-
case
|
|
65
|
+
case "--base":
|
|
60
66
|
args.base = requireValue(arg, argv[++i]);
|
|
61
67
|
break;
|
|
62
|
-
case
|
|
68
|
+
case "--head":
|
|
63
69
|
args.head = requireValue(arg, argv[++i]);
|
|
64
70
|
break;
|
|
65
|
-
case
|
|
71
|
+
case "--staged":
|
|
66
72
|
args.staged = true;
|
|
67
73
|
break;
|
|
68
|
-
case
|
|
74
|
+
case "--pr": {
|
|
69
75
|
const value = requireValue(arg, argv[++i]);
|
|
70
76
|
const number = Number(value);
|
|
71
77
|
if (!Number.isInteger(number) || number <= 0) {
|
|
@@ -74,29 +80,35 @@ function parseArgs(argv) {
|
|
|
74
80
|
args.pr = number;
|
|
75
81
|
break;
|
|
76
82
|
}
|
|
77
|
-
case
|
|
83
|
+
case "--repo":
|
|
78
84
|
args.repo = requireValue(arg, argv[++i]);
|
|
79
85
|
break;
|
|
80
|
-
case
|
|
86
|
+
case "--post":
|
|
81
87
|
args.post = true;
|
|
82
88
|
break;
|
|
83
|
-
case
|
|
89
|
+
case "--agents":
|
|
84
90
|
args.agents = requireValue(arg, argv[++i])
|
|
85
|
-
.split(
|
|
86
|
-
.map(id => id.trim())
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((id) => id.trim())
|
|
87
93
|
.filter(Boolean);
|
|
88
94
|
break;
|
|
89
|
-
case
|
|
95
|
+
case "--route":
|
|
90
96
|
args.route = true;
|
|
91
97
|
break;
|
|
92
|
-
case
|
|
98
|
+
case "--scope":
|
|
99
|
+
args.scope = requireValue(arg, argv[++i]);
|
|
100
|
+
break;
|
|
101
|
+
case "--config-dir":
|
|
102
|
+
args.configDir = requireValue(arg, argv[++i]);
|
|
103
|
+
break;
|
|
104
|
+
case "--json":
|
|
93
105
|
args.json = true;
|
|
94
106
|
break;
|
|
95
|
-
case
|
|
107
|
+
case "--no-fail":
|
|
96
108
|
args.noFail = true;
|
|
97
109
|
break;
|
|
98
|
-
case
|
|
99
|
-
case
|
|
110
|
+
case "-h":
|
|
111
|
+
case "--help":
|
|
100
112
|
args.help = true;
|
|
101
113
|
break;
|
|
102
114
|
default:
|
|
@@ -134,17 +146,90 @@ export async function reviewCommand(argv) {
|
|
|
134
146
|
process.chdir(root);
|
|
135
147
|
}
|
|
136
148
|
try {
|
|
137
|
-
const config = await loadReviewConfig(process.cwd());
|
|
138
149
|
const cwd = process.cwd();
|
|
139
|
-
const
|
|
150
|
+
const makeSource = () => args.pr != null
|
|
140
151
|
? new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd })
|
|
141
152
|
: new LocalGitSource({ base: args.base, head: args.head, staged: args.staged, cwd });
|
|
153
|
+
// --scope: load the named scope's config and review only that scope's files.
|
|
154
|
+
if (args.scope) {
|
|
155
|
+
// --scope and --config-dir are mutually exclusive (validateArgs), so
|
|
156
|
+
// args.configDir is undefined here; pass it through for consistency and so
|
|
157
|
+
// the manifest always resolves from the same dir as the root config.
|
|
158
|
+
const manifest = await loadRoutingManifest(cwd, { configDir: args.configDir });
|
|
159
|
+
if (!manifest) {
|
|
160
|
+
throw new Error("no .expo-code-review/routing.jsonc — --scope requires a routing manifest");
|
|
161
|
+
}
|
|
162
|
+
const scopeDef = manifest.scopes.find((scope) => scope.name === args.scope);
|
|
163
|
+
if (!scopeDef) {
|
|
164
|
+
throw new Error(`unknown scope "${args.scope}". Known scopes: ${manifest.scopes.map((s) => s.name).join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
const rootConfig = await loadReviewConfig(cwd);
|
|
167
|
+
const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
|
|
168
|
+
const source = memoizeSource(makeSource());
|
|
169
|
+
try {
|
|
170
|
+
const changed = await source.getChangedFiles();
|
|
171
|
+
const resolution = resolveScopes(manifest, changed.map((file) => file.path));
|
|
172
|
+
const files = resolution.active.find((scope) => scope.name === args.scope)?.files ?? [];
|
|
173
|
+
if (files.length === 0) {
|
|
174
|
+
process.stdout.write(`No changed files in scope ${args.scope}.\n`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const review = await runReview(source, {
|
|
178
|
+
config,
|
|
179
|
+
mode: "local",
|
|
180
|
+
agents: args.agents,
|
|
181
|
+
route: args.route,
|
|
182
|
+
includePaths: files,
|
|
183
|
+
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
184
|
+
});
|
|
185
|
+
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
186
|
+
if (args.post && args.pr != null) {
|
|
187
|
+
const repo = args.repo ?? (await resolveRepo(cwd));
|
|
188
|
+
// A scope always posts under the DERIVED marker `<rootTag>:<scope>` —
|
|
189
|
+
// from the ROOT config's tag exactly like `ecr ci` does (runRoutedCi
|
|
190
|
+
// prefers rootConfig.commentTag over manifest defaults when they
|
|
191
|
+
// diverge) — so a standalone scope post and CI's per-scope post/clear/
|
|
192
|
+
// reconcile paths always target the same marker, and the bare aggregate
|
|
193
|
+
// marker is never used here. (Per-scope commentTag overrides are
|
|
194
|
+
// rejected by the scope schema for exactly this reason.)
|
|
195
|
+
const tag = scopedCommentTag(rootConfig.commentTag, args.scope);
|
|
196
|
+
const reporter = new GitHubReporter({
|
|
197
|
+
prNumber: args.pr,
|
|
198
|
+
repo,
|
|
199
|
+
commentTag: tag,
|
|
200
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
201
|
+
cwd,
|
|
202
|
+
});
|
|
203
|
+
// Respect the author's break-glass opt-out, same as the non-scope path.
|
|
204
|
+
let breakGlass = false;
|
|
205
|
+
try {
|
|
206
|
+
breakGlass = await reporter.checkBreakGlass();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
breakGlass = false;
|
|
210
|
+
}
|
|
211
|
+
if (breakGlass) {
|
|
212
|
+
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
await reporter.report(review);
|
|
216
|
+
process.stderr.write(`\nPosted scope "${args.scope}" review to ${repo}#${args.pr}.\n`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await source.dispose();
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const config = await loadReviewConfig(cwd, { configDir: args.configDir });
|
|
226
|
+
const source = makeSource();
|
|
142
227
|
const review = await runReview(source, {
|
|
143
228
|
config,
|
|
144
|
-
mode:
|
|
229
|
+
mode: "local",
|
|
145
230
|
agents: args.agents,
|
|
146
231
|
route: args.route,
|
|
147
|
-
onProgress: message => process.stderr.write(`${message}\n`),
|
|
232
|
+
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
148
233
|
});
|
|
149
234
|
// Always print the result here first.
|
|
150
235
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
@@ -183,15 +268,18 @@ export async function reviewCommand(argv) {
|
|
|
183
268
|
/** Reject flag combinations that don't make sense together. */
|
|
184
269
|
function validateArgs(args) {
|
|
185
270
|
if (args.pr != null && (args.base || args.head || args.staged)) {
|
|
186
|
-
throw new Error(
|
|
271
|
+
throw new Error("--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.");
|
|
187
272
|
}
|
|
188
273
|
if (args.pr == null && (args.repo || args.post)) {
|
|
189
|
-
throw new Error(
|
|
274
|
+
throw new Error("--repo/--post only apply together with --pr.");
|
|
190
275
|
}
|
|
191
276
|
// --staged diffs the index against HEAD, so --base/--head have no effect. Reject
|
|
192
277
|
// the combination rather than silently ignoring the range the user asked for.
|
|
193
278
|
if (args.staged && (args.base || args.head)) {
|
|
194
|
-
throw new Error(
|
|
279
|
+
throw new Error("--staged reviews the staged changes (index vs HEAD) and cannot be combined with --base/--head.");
|
|
280
|
+
}
|
|
281
|
+
if (args.scope && args.configDir) {
|
|
282
|
+
throw new Error("--scope and --config-dir are mutually exclusive.");
|
|
195
283
|
}
|
|
196
284
|
}
|
|
197
285
|
/** Resolve owner/repo from the current checkout via gh (for --post). */
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
4
|
+
import { ROUTING_FILENAME } from "../config/routing.js";
|
|
5
|
+
import { repoRoot } from "../core/exec.js";
|
|
6
|
+
import { errorMessage } from "../core/util.js";
|
|
7
|
+
const USAGE = `ecr verify-config — refuse to run when a checked-out config could redirect the model credential
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
ecr verify-config [--expected <ENV_NAME>] [--json]
|
|
11
|
+
|
|
12
|
+
The canonical pre-review guard (ships with the CLI). It sweeps EVERY
|
|
13
|
+
.expo-code-review/config.jsonc|config.json and routing.jsonc in the repo via a
|
|
14
|
+
plain recursive walk (skipping node_modules/.git, so a staged-but-unreferenced
|
|
15
|
+
config can't hide from git's index), parses each with the real comment-aware JSONC
|
|
16
|
+
parser (never regex-scraping), and refuses to run (exit 1) when:
|
|
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;
|
|
21
|
+
• a non-root config declares auth, breakGlass, or commentTag (root-locked keys);
|
|
22
|
+
• any file fails to parse (fail-closed), reporting the parse error.
|
|
23
|
+
Exit 0 = safe to run the review.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--expected <ENVS> Require the declared tokenEnv set to equal this
|
|
27
|
+
comma-separated set (else ECR_EXPECTED_TOKEN_ENV).
|
|
28
|
+
--json Emit {ok, findings:[{file, problem}]} on stdout.
|
|
29
|
+
`;
|
|
30
|
+
const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
|
|
31
|
+
/**
|
|
32
|
+
* Discover every config the CLI could ever read via a plain recursive walk (not
|
|
33
|
+
* `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
|
|
34
|
+
* on-disk sweep the way it could from git's index. Skips node_modules and .git.
|
|
35
|
+
*/
|
|
36
|
+
async function discoverConfigFiles(root) {
|
|
37
|
+
const found = [];
|
|
38
|
+
async function walk(dir) {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return; // unreadable dir — nothing to sweep here
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
await walk(path.join(dir, entry.name));
|
|
52
|
+
}
|
|
53
|
+
else if (entry.isFile() &&
|
|
54
|
+
path.basename(dir) === CONFIG_DIRNAME &&
|
|
55
|
+
CONFIG_FILENAMES.has(entry.name)) {
|
|
56
|
+
found.push(path.join(dir, entry.name));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await walk(root);
|
|
61
|
+
return found.sort();
|
|
62
|
+
}
|
|
63
|
+
function asObject(value) {
|
|
64
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
65
|
+
? value
|
|
66
|
+
: undefined;
|
|
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
|
+
}
|
|
86
|
+
/** Read the security-relevant declarations from a parsed config/routing object. */
|
|
87
|
+
function extractFacts(file, parsed) {
|
|
88
|
+
if (path.basename(file) === ROUTING_FILENAME) {
|
|
89
|
+
// routing.jsonc locks auth under defaults.auth (defaults.auth.tokenEnv).
|
|
90
|
+
const defaults = asObject(parsed.defaults);
|
|
91
|
+
return {
|
|
92
|
+
tokenEnvs: collectTokenEnvs(asObject(defaults?.auth)),
|
|
93
|
+
declaresAuth: Boolean(defaults) && "auth" in defaults,
|
|
94
|
+
declaresBreakGlass: false, // routing.jsonc has no breakGlass concept
|
|
95
|
+
declaresCommentTag: Boolean(defaults) && "commentTag" in defaults,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
tokenEnvs: collectTokenEnvs(asObject(parsed.auth)),
|
|
100
|
+
declaresAuth: "auth" in parsed,
|
|
101
|
+
declaresBreakGlass: "breakGlass" in parsed,
|
|
102
|
+
declaresCommentTag: "commentTag" in parsed,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Verify every discoverable config is safe to run a review against. Fail-closed:
|
|
107
|
+
* any parse error, any tokenEnv anomaly, or any root-locked key in a non-root
|
|
108
|
+
* config is a finding. Never trusts the routing manifest — an unreferenced staged
|
|
109
|
+
* config dir is swept the same as a referenced one.
|
|
110
|
+
*/
|
|
111
|
+
export async function verifyConfig(root, options = {}) {
|
|
112
|
+
const findings = [];
|
|
113
|
+
const rootConfigDir = path.join(root, CONFIG_DIRNAME);
|
|
114
|
+
const rel = (file) => path.relative(root, file) || path.basename(file);
|
|
115
|
+
const files = await discoverConfigFiles(root);
|
|
116
|
+
const tokenEnvOccurrences = [];
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
const isRoot = path.dirname(file) === rootConfigDir;
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
const raw = await readFile(file, "utf8");
|
|
122
|
+
parsed = JSON.parse(stripTrailingCommas(stripJsonComments(raw)));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
findings.push({ file: rel(file), problem: `failed to parse: ${errorMessage(error)}` });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const object = asObject(parsed);
|
|
129
|
+
if (!object) {
|
|
130
|
+
findings.push({ file: rel(file), problem: "config is not a JSON object" });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const facts = extractFacts(file, object);
|
|
134
|
+
for (const value of facts.tokenEnvs) {
|
|
135
|
+
tokenEnvOccurrences.push({ file: rel(file), value, isRoot });
|
|
136
|
+
}
|
|
137
|
+
if (!isRoot) {
|
|
138
|
+
const locked = [];
|
|
139
|
+
if (facts.declaresAuth) {
|
|
140
|
+
locked.push("auth");
|
|
141
|
+
}
|
|
142
|
+
if (facts.declaresBreakGlass) {
|
|
143
|
+
locked.push("breakGlass");
|
|
144
|
+
}
|
|
145
|
+
if (facts.declaresCommentTag) {
|
|
146
|
+
locked.push("commentTag");
|
|
147
|
+
}
|
|
148
|
+
if (locked.length > 0) {
|
|
149
|
+
findings.push({
|
|
150
|
+
file: rel(file),
|
|
151
|
+
problem: `non-root config declares ${locked.join(", ")} — root-locked; only the root .expo-code-review config may set ${locked.length > 1 ? "them" : "it"}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// tokenEnvs may only be declared in root-owned files…
|
|
157
|
+
for (const occurrence of tokenEnvOccurrences.filter((o) => !o.isRoot)) {
|
|
158
|
+
findings.push({
|
|
159
|
+
file: occurrence.file,
|
|
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`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
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) {
|
|
169
|
+
findings.push({
|
|
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`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
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.
|
|
189
|
+
const expected = options.expected;
|
|
190
|
+
if (expected) {
|
|
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) {
|
|
198
|
+
findings.push({
|
|
199
|
+
file: path.join(CONFIG_DIRNAME, "config.jsonc"),
|
|
200
|
+
problem: `no tokenEnv found, but expected "${expectedSet.join(", ")}" — the root-owned config must name exactly those credential env(s)`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
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
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: findings.length === 0, findings };
|
|
211
|
+
}
|
|
212
|
+
/** CLI wrapper: parse flags, run the sweep, print, and set the exit code. */
|
|
213
|
+
export async function verifyConfigCommand(argv = []) {
|
|
214
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
215
|
+
process.stdout.write(USAGE);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const json = argv.includes("--json");
|
|
219
|
+
let expected = process.env.ECR_EXPECTED_TOKEN_ENV || undefined;
|
|
220
|
+
const expectedIdx = argv.indexOf("--expected");
|
|
221
|
+
if (expectedIdx >= 0) {
|
|
222
|
+
const value = argv[expectedIdx + 1];
|
|
223
|
+
if (!value || value.startsWith("-")) {
|
|
224
|
+
process.stderr.write("--expected requires a value (the env var name)\n");
|
|
225
|
+
process.exitCode = 2;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
expected = value;
|
|
229
|
+
}
|
|
230
|
+
const root = (await repoRoot()) ?? process.cwd();
|
|
231
|
+
const result = await verifyConfig(root, { expected });
|
|
232
|
+
// The sweep is deliberately repo-wide (a security check, not a config loader), so
|
|
233
|
+
// it is unaffected by ECR_CONFIG_DIR. But when the override is set, the root the
|
|
234
|
+
// loaders actually honor may not be ./.expo-code-review — note that so the two
|
|
235
|
+
// don't look inconsistent in a job log. JSON output stays machine-clean.
|
|
236
|
+
if (!json && process.env.ECR_CONFIG_DIR) {
|
|
237
|
+
process.stderr.write(` ℹ ECR_CONFIG_DIR is set (${process.env.ECR_CONFIG_DIR}); the honored root config may differ from ./${CONFIG_DIRNAME}. This sweep still scans the ENTIRE repo (unchanged).\n`);
|
|
238
|
+
}
|
|
239
|
+
if (json) {
|
|
240
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
241
|
+
}
|
|
242
|
+
else if (result.ok) {
|
|
243
|
+
process.stdout.write(`verify-config: OK — ${expected ? `tokenEnv locked to "${expected}"` : "no tokenEnv anomalies"}; no non-root config declares root-locked keys.\n`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
for (const finding of result.findings) {
|
|
247
|
+
process.stderr.write(`::error::${finding.problem} (${finding.file})\n`);
|
|
248
|
+
}
|
|
249
|
+
process.stderr.write(`verify-config: refusing to run — ${result.findings.length} problem(s) above.\n`);
|
|
250
|
+
}
|
|
251
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
252
|
+
}
|