@rtrentjones/greenlight 0.4.1 → 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/dist/bin.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ConfigSchema,
4
+ MATRIX,
4
5
  allPass,
6
+ describeMatrix,
5
7
  loadConfig,
6
8
  resolveUrl,
7
9
  scanSqlFiles,
8
10
  verifyAll
9
- } from "./chunk-P6FRYOOV.js";
11
+ } from "./chunk-OBWWE7GE.js";
10
12
  import "./chunk-HX7VA25D.js";
11
13
  import "./chunk-N3IKUCSF.js";
12
14
  import "./chunk-KP3Y6WRU.js";
@@ -333,11 +335,27 @@ var PACKS = [
333
335
  "Account \xB7 Cloudflare Tunnel \xB7 Edit (only if a tool uses target: oci)"
334
336
  ],
335
337
  verify: async (t) => {
338
+ const auth = { Authorization: `Bearer ${t}` };
336
339
  const r = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
337
- headers: { Authorization: `Bearer ${t}` }
340
+ headers: auth
338
341
  });
339
342
  const j = await r.json().catch(() => ({}));
340
- return { ok: r.ok && j.result?.status === "active", detail: j.result?.status };
343
+ if (!r.ok || j.result?.status !== "active") {
344
+ return { ok: false, detail: j.result?.status ?? `HTTP ${r.status}` };
345
+ }
346
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=1", {
347
+ headers: auth
348
+ }).catch(() => null);
349
+ if (ar?.ok) {
350
+ const aj = await ar.json().catch(() => ({}));
351
+ if (Array.isArray(aj.result) && aj.result.length === 0) {
352
+ return {
353
+ ok: false,
354
+ detail: "active but no account access \u2014 a Zone-DNS-only token can't resolve account_id; needs Account \xB7 Workers Scripts:Edit + Workers KV Storage:Edit + Account Settings:Read"
355
+ };
356
+ }
357
+ }
358
+ return { ok: true, detail: "active" };
341
359
  }
342
360
  }
343
361
  ],
@@ -583,7 +601,7 @@ function tokensForTool(tool) {
583
601
  }
584
602
 
585
603
  // src/version.ts
586
- var MODULE_REF = "v0.4.1";
604
+ var MODULE_REF = "v0.5.0";
587
605
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
588
606
  function moduleSource(module, ref = MODULE_REF) {
589
607
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -1063,11 +1081,11 @@ function hiddenPrompter() {
1063
1081
  if (tty) rl._writeToOutput = () => {
1064
1082
  };
1065
1083
  return {
1066
- ask: (query) => new Promise((resolve10) => {
1084
+ ask: (query) => new Promise((resolve11) => {
1067
1085
  process.stdout.write(query);
1068
1086
  rl.question("", (val) => {
1069
1087
  process.stdout.write("\n");
1070
- resolve10(val.trim());
1088
+ resolve11(val.trim());
1071
1089
  });
1072
1090
  }),
1073
1091
  close: () => rl.close()
@@ -1166,8 +1184,50 @@ async function gatherSecrets(name, repo, env, prefill) {
1166
1184
  console.log(`
1167
1185
  ${pushed} secret(s) pushed to ${repo}. (None written to disk.)`);
1168
1186
  }
1187
+ async function secretsCheck(name, repo) {
1188
+ const { config } = await loadManifest();
1189
+ const tools = name ? config.tools.filter((t) => t.name === name) : config.tools;
1190
+ if (name && tools.length === 0) throw new Error(`no tool "${name}" in the manifest`);
1191
+ const present = listGitHubSecrets(repo, void 0);
1192
+ console.log(`Secrets check \u2192 ${repo}`);
1193
+ if (!present) console.log("! could not list secrets (gh unauth/no access) \u2014 names only\n");
1194
+ else console.log("");
1195
+ let missing = 0;
1196
+ for (const t of tools) {
1197
+ const expected = /* @__PURE__ */ new Set();
1198
+ for (const pack of packsForTool({ lane: t.lane, target: t.target, data: t.data })) {
1199
+ for (const tok of pack.tokens) {
1200
+ if (!tok.optional) expected.add(secretKeyFor(tok, t.name, t.tokenOverrides));
1201
+ }
1202
+ }
1203
+ if (t.lane === "agent") {
1204
+ expected.add("GEMINI_API_KEY");
1205
+ expected.add("RUN_TOKEN");
1206
+ }
1207
+ for (const s of t.tokens ?? []) expected.add(s);
1208
+ console.log(
1209
+ `\u2500\u2500 ${t.name} (${t.lane}/${t.target}${t.data && t.data !== "none" ? `/${t.data}` : ""})`
1210
+ );
1211
+ for (const key of [...expected].sort()) {
1212
+ const have = present ? present.has(key) : void 0;
1213
+ if (have === false) missing++;
1214
+ console.log(` ${have === void 0 ? "?" : have ? "\u2714" : "\u2718"} ${key}`);
1215
+ }
1216
+ }
1217
+ if (present)
1218
+ console.log(`
1219
+ ${missing === 0 ? "\u2714 all required secrets present" : `\u2718 ${missing} missing`}`);
1220
+ process.exit(missing > 0 ? 1 : 0);
1221
+ }
1169
1222
  async function secretsCommand(args) {
1170
1223
  const sub = args[0];
1224
+ if (sub === "check") {
1225
+ const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
1226
+ const repo = flag(args, "--repo") ?? detectRepo(process.cwd());
1227
+ if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
1228
+ await secretsCheck(name, repo);
1229
+ return;
1230
+ }
1171
1231
  if (sub === "gather") {
1172
1232
  const name = args[1];
1173
1233
  if (!name || name.startsWith("-")) {
@@ -1229,7 +1289,23 @@ async function addCommand(args) {
1229
1289
  }
1230
1290
  const lane = flag2(args, "--lane");
1231
1291
  const target = flag2(args, "--target");
1232
- if (!lane || !target) throw new Error("add needs --lane and --target");
1292
+ if (!lane || !target) {
1293
+ throw new Error(
1294
+ `add needs --lane and --target. Valid combinations:
1295
+ ${describeMatrix()}
1296
+ (defaults: next\u2192vercel; astro/mcp/agent\u2192workers)`
1297
+ );
1298
+ }
1299
+ if (!(lane in MATRIX)) {
1300
+ throw new Error(`unknown lane "${lane}". Valid lanes:
1301
+ ${describeMatrix()}`);
1302
+ }
1303
+ const rule = MATRIX[lane];
1304
+ if (!rule.targets.includes(target)) {
1305
+ throw new Error(
1306
+ `lane "${lane}" can't target "${target}" \u2014 valid target(s): ${rule.targets.join(" | ")}`
1307
+ );
1308
+ }
1233
1309
  const { config, path } = await loadManifest();
1234
1310
  if (path.endsWith(".example.ts")) {
1235
1311
  throw new Error("no greenlight.config.ts \u2014 run `greenlight init` first");
@@ -2066,6 +2142,89 @@ function safeGit(cwd, gitArgs) {
2066
2142
  }
2067
2143
  }
2068
2144
 
2145
+ // src/commands/bump.ts
2146
+ import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2147
+ import { resolve as resolve7 } from "path";
2148
+
2149
+ // src/refs.ts
2150
+ import { readFileSync as readFileSync5, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
2151
+ import { join as join3 } from "path";
2152
+ var REF_RE = /greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g;
2153
+ function installedVersion(root) {
2154
+ try {
2155
+ const pkg = JSON.parse(
2156
+ readFileSync5(join3(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2157
+ );
2158
+ return pkg.version;
2159
+ } catch {
2160
+ return void 0;
2161
+ }
2162
+ }
2163
+ function infraRefs(root) {
2164
+ const refs = /* @__PURE__ */ new Set();
2165
+ try {
2166
+ for (const f of readdirSync2(join3(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
2167
+ for (const m of readFileSync5(join3(root, "infra", f), "utf8").matchAll(REF_RE)) {
2168
+ if (m[1]) refs.add(m[1]);
2169
+ }
2170
+ }
2171
+ } catch {
2172
+ }
2173
+ return [...refs];
2174
+ }
2175
+ function rewriteInfraRefs(root, target) {
2176
+ const want = target.startsWith("v") ? target : `v${target}`;
2177
+ const changed = [];
2178
+ let files;
2179
+ try {
2180
+ files = readdirSync2(join3(root, "infra")).filter((f) => f.endsWith(".tf"));
2181
+ } catch {
2182
+ return changed;
2183
+ }
2184
+ for (const f of files) {
2185
+ const p = join3(root, "infra", f);
2186
+ const body = readFileSync5(p, "utf8");
2187
+ const next = body.replace(
2188
+ /(greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=)v[0-9.]+/g,
2189
+ `$1${want}`
2190
+ );
2191
+ if (next !== body) {
2192
+ writeFileSync4(p, next);
2193
+ changed.push(f);
2194
+ }
2195
+ }
2196
+ return changed;
2197
+ }
2198
+
2199
+ // src/commands/bump.ts
2200
+ function bumpCommand(_args) {
2201
+ const root = process.cwd();
2202
+ const version = installedVersion(root);
2203
+ if (!version) {
2204
+ throw new Error(
2205
+ "no installed @rtrentjones/greenlight here \u2014 run from a consumer wrapper after `pnpm install`"
2206
+ );
2207
+ }
2208
+ const want = `v${version}`;
2209
+ const before = infraRefs(root);
2210
+ const changed = rewriteInfraRefs(root, version);
2211
+ if (changed.length) {
2212
+ console.log(`\u2714 re-pinned ${changed.length} infra file(s) \u2192 ${want}: ${changed.join(", ")}`);
2213
+ } else {
2214
+ console.log(`\xB7 infra ?ref already ${want}${before.length ? "" : " (no infra pins)"}`);
2215
+ }
2216
+ const pkgPath = resolve7(root, "package.json");
2217
+ if (existsSync7(pkgPath)) {
2218
+ const raw = readFileSync6(pkgPath, "utf8");
2219
+ const next = raw.replace(/("@rtrentjones\/greenlight":\s*")[^"]+(")/, `$1^${version}$2`);
2220
+ if (next !== raw) {
2221
+ writeFileSync5(pkgPath, next);
2222
+ console.log(`\u2714 package.json dep \u2192 ^${version}`);
2223
+ }
2224
+ }
2225
+ console.log("\nNext: pnpm install && pnpm greenlight doctor --strict, then commit + push.");
2226
+ }
2227
+
2069
2228
  // src/commands/config.ts
2070
2229
  import { relative } from "path";
2071
2230
  async function configCommand() {
@@ -2077,7 +2236,7 @@ async function configCommand() {
2077
2236
 
2078
2237
  // ../packages/adapters/src/index.ts
2079
2238
  import { execFileSync as execFileSync3 } from "child_process";
2080
- import { join as join3 } from "path";
2239
+ import { join as join4 } from "path";
2081
2240
  function run(cmd, args, cwd, extraEnv) {
2082
2241
  execFileSync3(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...extraEnv } });
2083
2242
  }
@@ -2093,7 +2252,7 @@ function workersAdapter(ctx) {
2093
2252
  siteEnv = void 0;
2094
2253
  }
2095
2254
  run("pnpm", ["run", "build"], toolDir, siteEnv);
2096
- return { artifactDir: join3(toolDir, "dist") };
2255
+ return { artifactDir: join4(toolDir, "dist") };
2097
2256
  },
2098
2257
  async deploy(toolDir, env) {
2099
2258
  run("pnpm", ["exec", "wrangler", "deploy", "--env", env], toolDir);
@@ -2195,20 +2354,84 @@ async function deployCommand(args) {
2195
2354
  // src/commands/doctor.ts
2196
2355
  import { execFileSync as execFileSync4 } from "child_process";
2197
2356
  import { lookup } from "dns/promises";
2198
- import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
2199
- import { join as join4 } from "path";
2357
+ import { existsSync as existsSync9, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
2358
+ import { join as join6 } from "path";
2359
+
2360
+ // src/commands/migrations.ts
2361
+ import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2362
+ import { join as join5 } from "path";
2363
+ var DEFAULT_DIR = "supabase/migrations";
2364
+ var CANDIDATE_DIRS = [
2365
+ DEFAULT_DIR,
2366
+ "migrations",
2367
+ "drizzle/migrations",
2368
+ "drizzle",
2369
+ "db/migrations"
2370
+ ];
2371
+ function resolveMigrationsDir(explicit, root = process.cwd()) {
2372
+ if (explicit) return explicit;
2373
+ return CANDIDATE_DIRS.find((d) => existsSync8(join5(root, d))) ?? DEFAULT_DIR;
2374
+ }
2375
+ async function migrationsCommand(args) {
2376
+ if (args[0] !== "scan") {
2377
+ console.log(
2378
+ `usage: greenlight migrations scan [<dir>] [--strict]
2379
+ scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
2380
+ no <dir> \u2192 auto-detects ${CANDIDATE_DIRS.join(" | ")}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
2381
+ );
2382
+ process.exit(args[0] ? 1 : 0);
2383
+ }
2384
+ const dir = resolveMigrationsDir(args.slice(1).find((a) => !a.startsWith("-")));
2385
+ const strict = args.includes("--strict");
2386
+ let names;
2387
+ try {
2388
+ names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
2389
+ } catch {
2390
+ console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
2391
+ process.exit(0);
2392
+ }
2393
+ if (names.length === 0) {
2394
+ console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
2395
+ process.exit(0);
2396
+ }
2397
+ const files = names.map((f) => ({
2398
+ path: join5(dir, f),
2399
+ content: readFileSync7(join5(dir, f), "utf8")
2400
+ }));
2401
+ const findings = scanSqlFiles(files);
2402
+ if (findings.length === 0) {
2403
+ console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
2404
+ process.exit(0);
2405
+ }
2406
+ for (const f of findings) {
2407
+ console.log(
2408
+ ` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
2409
+ ${f.snippet}`
2410
+ );
2411
+ }
2412
+ const dangers = findings.filter((f) => f.severity === "danger");
2413
+ const blocking = strict ? findings : dangers;
2414
+ const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
2415
+ console.log(
2416
+ `
2417
+ ${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
2418
+ );
2419
+ process.exit(blocking.length === 0 ? 0 : 1);
2420
+ }
2421
+
2422
+ // src/commands/doctor.ts
2200
2423
  function dirCheck(label, dir) {
2201
- return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2424
+ return existsSync9(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2202
2425
  }
2203
2426
  function conformanceChecks(t, root) {
2204
2427
  const out = [];
2205
- const toolDir = t.dir ?? join4("tools", t.name);
2428
+ const toolDir = t.dir ?? join6("tools", t.name);
2206
2429
  const specCandidates = t.external ? [
2207
2430
  `verify/${t.name}.config.ts`,
2208
- join4(toolDir, `verify/${t.name}.config.ts`),
2209
- join4(toolDir, "verify.config.ts")
2210
- ] : [join4(toolDir, "verify.config.ts")];
2211
- const found = specCandidates.find((p) => existsSync7(join4(root, p)));
2431
+ join6(toolDir, `verify/${t.name}.config.ts`),
2432
+ join6(toolDir, "verify.config.ts")
2433
+ ] : [join6(toolDir, "verify.config.ts")];
2434
+ const found = specCandidates.find((p) => existsSync9(join6(root, p)));
2212
2435
  out.push({
2213
2436
  name: `${t.name}: in the verify loop`,
2214
2437
  status: found ? "ok" : "warn",
@@ -2233,58 +2456,61 @@ function conformanceChecks(t, root) {
2233
2456
  });
2234
2457
  }
2235
2458
  if (!t.external && t.lane === "next" && t.target === "vercel") {
2236
- const wsPath = join4(root, "pnpm-workspace.yaml");
2237
- const ws = existsSync7(wsPath) ? readFileSync5(wsPath, "utf8") : "";
2459
+ const wsPath = join6(root, "pnpm-workspace.yaml");
2460
+ const ws = existsSync9(wsPath) ? readFileSync8(wsPath, "utf8") : "";
2238
2461
  const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
2239
2462
  out.push({
2240
2463
  name: `${t.name}: pnpm workspace member`,
2241
2464
  status: member ? "ok" : "warn",
2242
2465
  detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
2243
2466
  });
2244
- const hasVercelJson = existsSync7(join4(root, toolDir, "vercel.json"));
2467
+ const hasVercelJson = existsSync9(join6(root, toolDir, "vercel.json"));
2245
2468
  out.push({
2246
2469
  name: `${t.name}: vercel.json framework`,
2247
2470
  status: hasVercelJson ? "ok" : "warn",
2248
- detail: hasVercelJson ? void 0 : `no ${join4(toolDir, "vercel.json")} (framework: "nextjs") \u2014 Vercel may treat the build as static`
2471
+ detail: hasVercelJson ? void 0 : `no ${join6(toolDir, "vercel.json")} (framework: "nextjs") \u2014 Vercel may treat the build as static`
2249
2472
  });
2250
2473
  }
2474
+ if (t.data === "supabase" || t.data === "neon") {
2475
+ const migBase = join6(root, toolDir);
2476
+ const migDir = resolveMigrationsDir(void 0, migBase);
2477
+ if (existsSync9(join6(migBase, migDir))) {
2478
+ const wired = [join6(migBase, ".github/workflows"), join6(root, ".github/workflows")].some(
2479
+ (d) => {
2480
+ try {
2481
+ return readdirSync4(d).filter((f) => /\.ya?ml$/.test(f)).some((f) => readFileSync8(join6(d, f), "utf8").includes("migrations scan"));
2482
+ } catch {
2483
+ return false;
2484
+ }
2485
+ }
2486
+ );
2487
+ out.push({
2488
+ name: `${t.name}: migrations gate`,
2489
+ status: wired ? "ok" : "warn",
2490
+ detail: wired ? `${migDir} scanned in CI` : `${migDir} present but no workflow runs \`greenlight migrations scan\` \u2014 wire the dangerous-SQL gate before the apply step`
2491
+ });
2492
+ }
2493
+ }
2251
2494
  return out;
2252
2495
  }
2253
2496
  function versionDriftCheck(root) {
2254
2497
  const name = "framework version drift";
2255
- let installed;
2256
- try {
2257
- const pkg = JSON.parse(
2258
- readFileSync5(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2259
- );
2260
- installed = pkg.version;
2261
- } catch {
2262
- }
2263
- const refs = /* @__PURE__ */ new Set();
2264
- try {
2265
- for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
2266
- const body = readFileSync5(join4(root, "infra", f), "utf8");
2267
- for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
2268
- if (m[1]) refs.add(m[1]);
2269
- }
2270
- }
2271
- } catch {
2272
- }
2273
- if (!installed && refs.size === 0) {
2498
+ const installed = installedVersion(root);
2499
+ const refList = infraRefs(root);
2500
+ if (!installed && refList.length === 0) {
2274
2501
  return {
2275
2502
  name,
2276
2503
  status: "skip",
2277
2504
  detail: "no installed @rtrentjones/greenlight or infra pins here"
2278
2505
  };
2279
2506
  }
2280
- const refList = [...refs];
2281
2507
  if (installed) {
2282
2508
  const want = `v${installed}`;
2283
2509
  const bad = refList.filter((r) => r !== want);
2284
2510
  return bad.length === 0 ? { name, status: "ok", detail: `infra pins == installed ${want}` } : {
2285
2511
  name,
2286
2512
  status: "warn",
2287
- detail: `installed ${want}, but infra pins ${bad.join(", ")} \u2014 bump ?ref to ${want}`
2513
+ detail: `installed ${want}, but infra pins ${bad.join(", ")} \u2014 run \`greenlight bump\``
2288
2514
  };
2289
2515
  }
2290
2516
  return refList.length <= 1 ? { name, status: "ok", detail: `infra pins uniform (${refList[0] ?? "none"})` } : { name, status: "warn", detail: `infra ?ref pins not uniform: ${refList.join(", ")}` };
@@ -2307,7 +2533,7 @@ function submoduleDriftCheck(root) {
2307
2533
  }
2308
2534
  function runDoctor(config, root) {
2309
2535
  const checks = [];
2310
- if (config.blog) checks.push(dirCheck("blog", join4(root, "apps/blog")));
2536
+ if (config.blog) checks.push(dirCheck("blog", join6(root, "apps/blog")));
2311
2537
  for (const t of config.tools) {
2312
2538
  if (t.external) {
2313
2539
  const url = resolveUrl({
@@ -2319,7 +2545,7 @@ function runDoctor(config, root) {
2319
2545
  checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
2320
2546
  if (t.dir) {
2321
2547
  checks.push(
2322
- existsSync7(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2548
+ existsSync9(join6(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2323
2549
  name: `${t.name}: dir present`,
2324
2550
  status: "warn",
2325
2551
  detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
@@ -2327,7 +2553,7 @@ function runDoctor(config, root) {
2327
2553
  );
2328
2554
  }
2329
2555
  } else {
2330
- checks.push(dirCheck(t.name, join4(root, t.dir ?? join4("tools", t.name))));
2556
+ checks.push(dirCheck(t.name, join6(root, t.dir ?? join6("tools", t.name))));
2331
2557
  }
2332
2558
  checks.push(...conformanceChecks(t, root));
2333
2559
  }
@@ -2394,6 +2620,7 @@ async function doctorCommand(args = []) {
2394
2620
  process.exit(1);
2395
2621
  }
2396
2622
  const live = args.includes("--live");
2623
+ const strict = args.includes("--strict");
2397
2624
  const checks = runDoctor(config, process.cwd());
2398
2625
  if (live) {
2399
2626
  console.log(" (probing live prod URLs\u2026)");
@@ -2403,15 +2630,19 @@ async function doctorCommand(args = []) {
2403
2630
  console.log(` ${ICON[c.status]} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
2404
2631
  }
2405
2632
  const failed = checks.filter((c) => c.status === "fail").length;
2406
- console.log(`
2407
- ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2633
+ const warned = checks.filter((c) => c.status === "warn").length;
2634
+ console.log(
2635
+ `
2636
+ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}${warned ? ` \xB7 ${warned} warning(s)` : ""}`
2637
+ );
2408
2638
  if (!live) console.log("\xB7 run `greenlight doctor --live` for DNS + reachability probes");
2409
- process.exit(failed === 0 ? 0 : 1);
2639
+ if (!strict && warned) console.log("\xB7 run `greenlight doctor --strict` to fail on warnings (CI)");
2640
+ process.exit(failed > 0 || strict && warned > 0 ? 1 : 0);
2410
2641
  }
2411
2642
 
2412
2643
  // src/commands/init.ts
2413
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2414
- import { resolve as resolve7 } from "path";
2644
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
2645
+ import { resolve as resolve8 } from "path";
2415
2646
  import { createInterface as createInterface2 } from "readline/promises";
2416
2647
 
2417
2648
  // src/tokens.ts
@@ -2534,8 +2765,9 @@ jobs:
2534
2765
  TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
2535
2766
  GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
2536
2767
  CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
2537
- TF_VAR_cloudflare_zone_id: \${{ secrets.TF_VAR_CLOUDFLARE_ZONE_ID }}
2538
- TF_VAR_cloudflare_account_id: \${{ secrets.TF_VAR_CLOUDFLARE_ACCOUNT_ID }}
2768
+ # zone/account ids are enumerable identifiers, not secrets \u2014 repo VARIABLES (vars.*)
2769
+ TF_VAR_cloudflare_zone_id: \${{ vars.CLOUDFLARE_ZONE_ID }}
2770
+ TF_VAR_cloudflare_account_id: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
2539
2771
  # vercel (target: vercel tools)
2540
2772
  VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
2541
2773
  # supabase (data: supabase tools)
@@ -2560,12 +2792,12 @@ jobs:
2560
2792
  `;
2561
2793
  }
2562
2794
  function scaffoldIfAbsent(path, contents, label) {
2563
- if (existsSync8(path)) {
2795
+ if (existsSync10(path)) {
2564
2796
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
2565
2797
  return;
2566
2798
  }
2567
- mkdirSync4(resolve7(path, ".."), { recursive: true });
2568
- writeFileSync4(path, contents);
2799
+ mkdirSync4(resolve8(path, ".."), { recursive: true });
2800
+ writeFileSync6(path, contents);
2569
2801
  console.log(`\u2714 wrote ${label}`);
2570
2802
  }
2571
2803
  var TOKEN_FLAGS = {
@@ -2586,22 +2818,22 @@ async function initCommand(args) {
2586
2818
  }
2587
2819
  if (!domain) throw new Error("a domain is required");
2588
2820
  const cwd = process.cwd();
2589
- const configPath = resolve7(cwd, "greenlight.config.ts");
2590
- if (existsSync8(configPath) && !force) {
2821
+ const configPath = resolve8(cwd, "greenlight.config.ts");
2822
+ if (existsSync10(configPath) && !force) {
2591
2823
  throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
2592
2824
  }
2593
- writeFileSync4(configPath, scaffoldConfig(domain));
2825
+ writeFileSync6(configPath, scaffoldConfig(domain));
2594
2826
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2595
2827
  const repoName = domain.replace(/\./g, "-");
2596
2828
  scaffoldIfAbsent(
2597
- resolve7(cwd, ".github/workflows/infra.yml"),
2829
+ resolve8(cwd, ".github/workflows/infra.yml"),
2598
2830
  wrapperInfraYml(),
2599
2831
  ".github/workflows/infra.yml (HCP-backed terraform apply on push)"
2600
2832
  );
2601
- scaffoldIfAbsent(resolve7(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2602
- scaffoldIfAbsent(resolve7(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2603
- scaffoldIfAbsent(resolve7(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2604
- scaffoldIfAbsent(resolve7(cwd, ".node-version"), "24\n", ".node-version");
2833
+ scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2834
+ scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2835
+ scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2836
+ scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
2605
2837
  const repo = flag5(args, "--repo") ?? detectRepo(cwd);
2606
2838
  let pushed = 0;
2607
2839
  if (repo && !args.includes("--no-push")) {
@@ -2646,76 +2878,14 @@ Next:
2646
2878
  4. greenlight verify <name> --env prod | greenlight doctor`);
2647
2879
  }
2648
2880
 
2649
- // src/commands/migrations.ts
2650
- import { existsSync as existsSync9, readFileSync as readFileSync6, readdirSync as readdirSync3 } from "fs";
2651
- import { join as join5 } from "path";
2652
- var DEFAULT_DIR = "supabase/migrations";
2653
- var CANDIDATE_DIRS = [
2654
- DEFAULT_DIR,
2655
- "migrations",
2656
- "drizzle/migrations",
2657
- "drizzle",
2658
- "db/migrations"
2659
- ];
2660
- function resolveMigrationsDir(explicit, root = process.cwd()) {
2661
- if (explicit) return explicit;
2662
- return CANDIDATE_DIRS.find((d) => existsSync9(join5(root, d))) ?? DEFAULT_DIR;
2663
- }
2664
- async function migrationsCommand(args) {
2665
- if (args[0] !== "scan") {
2666
- console.log(
2667
- `usage: greenlight migrations scan [<dir>] [--strict]
2668
- scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
2669
- no <dir> \u2192 auto-detects ${CANDIDATE_DIRS.join(" | ")}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
2670
- );
2671
- process.exit(args[0] ? 1 : 0);
2672
- }
2673
- const dir = resolveMigrationsDir(args.slice(1).find((a) => !a.startsWith("-")));
2674
- const strict = args.includes("--strict");
2675
- let names;
2676
- try {
2677
- names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
2678
- } catch {
2679
- console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
2680
- process.exit(0);
2681
- }
2682
- if (names.length === 0) {
2683
- console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
2684
- process.exit(0);
2685
- }
2686
- const files = names.map((f) => ({
2687
- path: join5(dir, f),
2688
- content: readFileSync6(join5(dir, f), "utf8")
2689
- }));
2690
- const findings = scanSqlFiles(files);
2691
- if (findings.length === 0) {
2692
- console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
2693
- process.exit(0);
2694
- }
2695
- for (const f of findings) {
2696
- console.log(
2697
- ` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
2698
- ${f.snippet}`
2699
- );
2700
- }
2701
- const dangers = findings.filter((f) => f.severity === "danger");
2702
- const blocking = strict ? findings : dangers;
2703
- const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
2704
- console.log(
2705
- `
2706
- ${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
2707
- );
2708
- process.exit(blocking.length === 0 ? 0 : 1);
2709
- }
2710
-
2711
2881
  // src/commands/preview.ts
2712
2882
  import { execFileSync as execFileSync5, spawn } from "child_process";
2713
- import { resolve as resolve9 } from "path";
2883
+ import { resolve as resolve10 } from "path";
2714
2884
  import { setTimeout as sleep } from "timers/promises";
2715
2885
 
2716
2886
  // src/commands/verify.ts
2717
2887
  import { spawnSync } from "child_process";
2718
- import { resolve as resolve8 } from "path";
2888
+ import { resolve as resolve9 } from "path";
2719
2889
  function defaultSpec(lane) {
2720
2890
  switch (lane) {
2721
2891
  case "astro":
@@ -2843,7 +3013,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
2843
3013
  if (reachableTimeoutMs > 0) {
2844
3014
  console.log(`waiting up to ${reachableTimeoutMs / 1e3}s for ${url} to become reachable\u2026`);
2845
3015
  }
2846
- const toolDir = resolve8(process.cwd(), entry.dir ?? ".");
3016
+ const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2847
3017
  const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
2848
3018
  attachFailureLogs(reports, specs, toolDir);
2849
3019
  for (const report of reports) printReport(report);
@@ -2887,7 +3057,7 @@ async function verifyLocal(entry, url) {
2887
3057
  process.env.GREENLIGHT_PREVIEW = "1";
2888
3058
  process.env.GREENLIGHT_VERIFY_URL = url;
2889
3059
  const specs = await loadSpecs(entry);
2890
- const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
3060
+ const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2891
3061
  const reports = await verifyAll(url, specs, { toolDir });
2892
3062
  for (const report of reports) printReport(report);
2893
3063
  return allPass(reports);
@@ -2898,7 +3068,7 @@ async function previewViaDescriptor(entry, name, portOverride) {
2898
3068
  const port = portOverride ?? pv.port ?? entry.port ?? lane.port;
2899
3069
  const path = pv.path ?? lane.path;
2900
3070
  const url = `http://localhost:${port}${path}`;
2901
- const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
3071
+ const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2902
3072
  console.log(`preview ${name}: ${pv.command} (\u2192 ${url})`);
2903
3073
  const child = spawn(pv.command, {
2904
3074
  cwd: toolDir,
@@ -3204,6 +3374,7 @@ var HELP = `greenlight <command>
3204
3374
 
3205
3375
  init --domain <d> [--cf-token ..] [--force] scaffold manifest + secrets, push to GitHub Actions
3206
3376
  add <name> --lane <l> --target <t> [..] scaffold a tool from a lane template + manifest entry
3377
+ lanes list the valid lane \xD7 target \xD7 data combinations
3207
3378
  config load & validate the manifest, then print it
3208
3379
  deploy <name> --env <env> build + deploy an entry via its target adapter
3209
3380
  preview <name> [--port <n>] build + serve locally + verify (one command)
@@ -3211,10 +3382,12 @@ var HELP = `greenlight <command>
3211
3382
  promote <name> [--perform] [--push] gated develop -> main fast-forward
3212
3383
  status <name> last ship/deploy/verify run for a tool (via gh)
3213
3384
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
3385
+ secrets check [<name>] [--repo o/r] list the GitHub secrets a tool's deploy needs + flag missing
3214
3386
  agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
3215
3387
  adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
3216
3388
  migrations scan [<dir>] [--strict] dangerous-SQL gate for migrations (pre-apply)
3217
- doctor [--live] consistency checks (--live: DNS + reachability probes)
3389
+ bump re-pin a consumer's infra ?ref + dep to the installed version
3390
+ doctor [--live] [--strict] consistency checks (--live: probes; --strict: fail on warnings)
3218
3391
  help show this message
3219
3392
 
3220
3393
  Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see docs/archive/greenlight-v1.md \xA716.`;
@@ -3231,6 +3404,10 @@ async function main() {
3231
3404
  return initCommand(args);
3232
3405
  case "add":
3233
3406
  return addCommand(args);
3407
+ case "lanes":
3408
+ console.log(`Valid lane \xD7 target \xD7 data combinations:
3409
+ ${describeMatrix()}`);
3410
+ return;
3234
3411
  case "config":
3235
3412
  return configCommand();
3236
3413
  case "deploy":
@@ -3251,6 +3428,8 @@ async function main() {
3251
3428
  return adoptCommand(args);
3252
3429
  case "migrations":
3253
3430
  return migrationsCommand(args);
3431
+ case "bump":
3432
+ return bumpCommand(args);
3254
3433
  case "doctor":
3255
3434
  return doctorCommand(args);
3256
3435
  default: