@rtrentjones/greenlight 0.2.22 → 0.2.24

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  verifyAgentWeb
3
- } from "./chunk-UXHHLEYO.js";
3
+ } from "./chunk-KVOI4UL2.js";
4
4
  import "./chunk-QFKE5JKC.js";
5
5
  export {
6
6
  verifyAgentWeb
package/dist/bin.js CHANGED
@@ -4,13 +4,14 @@ import {
4
4
  allPass,
5
5
  loadConfig,
6
6
  resolveUrl,
7
+ scanSqlFiles,
7
8
  verifyAll
8
- } from "./chunk-GO2RVNOP.js";
9
+ } from "./chunk-2A7ZBBYN.js";
9
10
  import "./chunk-HX7VA25D.js";
10
11
  import "./chunk-N3IKUCSF.js";
11
12
  import "./chunk-KP3Y6WRU.js";
12
- import "./chunk-UXHHLEYO.js";
13
- import "./chunk-6N7MD6FR.js";
13
+ import "./chunk-KVOI4UL2.js";
14
+ import "./chunk-XWTOJHLV.js";
14
15
  import "./chunk-QFKE5JKC.js";
15
16
 
16
17
  // src/commands/add.ts
@@ -443,7 +444,7 @@ function tokensForTool(tool) {
443
444
  }
444
445
 
445
446
  // src/version.ts
446
- var MODULE_REF = "v0.2.22";
447
+ var MODULE_REF = "v0.2.24";
447
448
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
448
449
  function moduleSource(module, ref = MODULE_REF) {
449
450
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -777,10 +778,22 @@ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
777
778
  async function agentCommand(args) {
778
779
  if (args[0] !== "sync") {
779
780
  console.log(
780
- "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
781
+ "usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
781
782
  );
782
783
  process.exit(args[0] ? 1 : 0);
783
784
  }
785
+ const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
786
+ if (name) {
787
+ const { config } = await loadManifest();
788
+ const entry = resolveEntry(config, name);
789
+ const dir = resolve3(process.cwd(), entry.dir ?? ".");
790
+ materializeAgentKit(dir, { target: entry.target, data: entry.data });
791
+ console.log(
792
+ `
793
+ Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (target=${entry.target}, data=${entry.data}).`
794
+ );
795
+ return;
796
+ }
784
797
  materializeAgentKit(process.cwd());
785
798
  console.log(
786
799
  "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
@@ -1991,7 +2004,9 @@ async function deployCommand(args) {
1991
2004
  }
1992
2005
 
1993
2006
  // src/commands/doctor.ts
1994
- import { existsSync as existsSync7 } from "fs";
2007
+ import { execFileSync as execFileSync4 } from "child_process";
2008
+ import { lookup } from "dns/promises";
2009
+ import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
1995
2010
  import { join as join4 } from "path";
1996
2011
  function dirCheck(label, dir) {
1997
2012
  return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
@@ -2030,6 +2045,61 @@ function conformanceChecks(t, root) {
2030
2045
  }
2031
2046
  return out;
2032
2047
  }
2048
+ function versionDriftCheck(root) {
2049
+ const name = "framework version drift";
2050
+ let installed;
2051
+ try {
2052
+ const pkg = JSON.parse(
2053
+ readFileSync5(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2054
+ );
2055
+ installed = pkg.version;
2056
+ } catch {
2057
+ }
2058
+ const refs = /* @__PURE__ */ new Set();
2059
+ try {
2060
+ for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
2061
+ const body = readFileSync5(join4(root, "infra", f), "utf8");
2062
+ for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
2063
+ if (m[1]) refs.add(m[1]);
2064
+ }
2065
+ }
2066
+ } catch {
2067
+ }
2068
+ if (!installed && refs.size === 0) {
2069
+ return {
2070
+ name,
2071
+ status: "skip",
2072
+ detail: "no installed @rtrentjones/greenlight or infra pins here"
2073
+ };
2074
+ }
2075
+ const refList = [...refs];
2076
+ if (installed) {
2077
+ const want = `v${installed}`;
2078
+ const bad = refList.filter((r) => r !== want);
2079
+ return bad.length === 0 ? { name, status: "ok", detail: `infra pins == installed ${want}` } : {
2080
+ name,
2081
+ status: "warn",
2082
+ detail: `installed ${want}, but infra pins ${bad.join(", ")} \u2014 bump ?ref to ${want}`
2083
+ };
2084
+ }
2085
+ 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(", ")}` };
2086
+ }
2087
+ function submoduleDriftCheck(root) {
2088
+ const name = "submodule drift";
2089
+ let out;
2090
+ try {
2091
+ out = execFileSync4("git", ["submodule", "status"], {
2092
+ cwd: root,
2093
+ encoding: "utf8",
2094
+ stdio: ["ignore", "pipe", "ignore"]
2095
+ }).trim();
2096
+ } catch {
2097
+ return { name, status: "skip", detail: "no git / not a repo" };
2098
+ }
2099
+ if (!out) return { name, status: "skip", detail: "no submodules" };
2100
+ const dirty = out.split("\n").filter((l) => /^[+\-U]/.test(l));
2101
+ return dirty.length === 0 ? { name, status: "ok", detail: "all submodules match their recorded commit" } : { name, status: "warn", detail: dirty.map((l) => l.trim()).join("; ") };
2102
+ }
2033
2103
  function runDoctor(config, root) {
2034
2104
  const checks = [];
2035
2105
  if (config.blog) checks.push(dirCheck("blog", join4(root, "apps/blog")));
@@ -2042,6 +2112,15 @@ function runDoctor(config, root) {
2042
2112
  mcp: t.lane === "mcp"
2043
2113
  });
2044
2114
  checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
2115
+ if (t.dir) {
2116
+ checks.push(
2117
+ existsSync7(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2118
+ name: `${t.name}: dir present`,
2119
+ status: "warn",
2120
+ detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
2121
+ }
2122
+ );
2123
+ }
2045
2124
  } else {
2046
2125
  checks.push(dirCheck(t.name, join4(root, t.dir ?? join4("tools", t.name))));
2047
2126
  }
@@ -2053,20 +2132,54 @@ function runDoctor(config, root) {
2053
2132
  status: needsKeepalive.length > 0 ? "ok" : "skip",
2054
2133
  detail: needsKeepalive.length > 0 ? needsKeepalive.map((t) => `${t.name} (${t.data === "supabase" ? "supabase" : "oci"})`).join(", ") : "no data:supabase / target:oci tools"
2055
2134
  });
2056
- for (const name of [
2057
- "DNS propagation",
2058
- "terraform drift",
2059
- "Vercel cap headroom",
2060
- "keepalive health (live)",
2061
- "OCI PAYG status",
2062
- "framework version drift"
2063
- ]) {
2064
- checks.push({ name, status: "skip", detail: "needs provider creds / packages (Phase 5/7/8)" });
2135
+ checks.push(versionDriftCheck(root));
2136
+ checks.push(submoduleDriftCheck(root));
2137
+ return checks;
2138
+ }
2139
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
2140
+ async function livenessCheck(name, url) {
2141
+ try {
2142
+ const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(1e4) });
2143
+ return res.status >= 500 ? { name: `${name}: live`, status: "warn", detail: `${url} \u2192 ${res.status} (degraded)` } : { name: `${name}: live`, status: "ok", detail: `${url} \u2192 ${res.status}` };
2144
+ } catch (e) {
2145
+ return { name: `${name}: live`, status: "fail", detail: `${url} unreachable: ${errMsg(e)}` };
2146
+ }
2147
+ }
2148
+ async function dnsCheck(name, url) {
2149
+ const host = new URL(url).hostname;
2150
+ try {
2151
+ await lookup(host);
2152
+ return { name: `${name}: DNS`, status: "ok", detail: host };
2153
+ } catch {
2154
+ return { name: `${name}: DNS`, status: "fail", detail: `${host} does not resolve` };
2155
+ }
2156
+ }
2157
+ async function runDoctorLive(config) {
2158
+ const targets = [];
2159
+ if (config.blog) targets.push({ name: "blog", url: `https://${config.domain}` });
2160
+ for (const t of config.tools) {
2161
+ if (!t.envs?.includes("prod")) continue;
2162
+ targets.push({
2163
+ name: t.name,
2164
+ url: resolveUrl({ domain: config.domain, name: t.name, env: "prod", mcp: t.lane === "mcp" })
2165
+ });
2166
+ }
2167
+ const checks = [];
2168
+ for (const tg of targets) {
2169
+ checks.push(await dnsCheck(tg.name, tg.url));
2170
+ checks.push(await livenessCheck(tg.name, tg.url));
2171
+ }
2172
+ for (const name of ["terraform drift", "Vercel cap headroom", "OCI PAYG status"]) {
2173
+ checks.push({
2174
+ name,
2175
+ status: "skip",
2176
+ detail: "not yet implemented \u2014 needs the provider API + creds"
2177
+ });
2065
2178
  }
2066
2179
  return checks;
2067
2180
  }
2068
2181
  var ICON = { ok: "\u2714", warn: "!", fail: "\u2718", skip: "\xB7" };
2069
- async function doctorCommand() {
2182
+ async function doctorCommand(args = []) {
2070
2183
  let config;
2071
2184
  try {
2072
2185
  ({ config } = await loadManifest());
@@ -2075,13 +2188,19 @@ async function doctorCommand() {
2075
2188
  console.error(`\u2718 manifest: ${e instanceof Error ? e.message : String(e)}`);
2076
2189
  process.exit(1);
2077
2190
  }
2191
+ const live = args.includes("--live");
2078
2192
  const checks = runDoctor(config, process.cwd());
2193
+ if (live) {
2194
+ console.log(" (probing live prod URLs\u2026)");
2195
+ checks.push(...await runDoctorLive(config));
2196
+ }
2079
2197
  for (const c of checks) {
2080
2198
  console.log(` ${ICON[c.status]} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
2081
2199
  }
2082
2200
  const failed = checks.filter((c) => c.status === "fail").length;
2083
2201
  console.log(`
2084
2202
  ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2203
+ if (!live) console.log("\xB7 run `greenlight doctor --live` for DNS + reachability probes");
2085
2204
  process.exit(failed === 0 ? 0 : 1);
2086
2205
  }
2087
2206
 
@@ -2091,7 +2210,7 @@ import { resolve as resolve8 } from "path";
2091
2210
  import { createInterface as createInterface3 } from "readline/promises";
2092
2211
 
2093
2212
  // src/tokens.ts
2094
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2213
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2095
2214
  import { resolve as resolve7 } from "path";
2096
2215
  import { createInterface as createInterface2 } from "readline/promises";
2097
2216
  var SECRETS_DIR = ".greenlight";
@@ -2100,7 +2219,7 @@ function presentEnv(cwd) {
2100
2219
  const out = {};
2101
2220
  const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
2102
2221
  if (existsSync8(p)) {
2103
- for (const { key, value } of parseSecretsEnv(readFileSync5(p, "utf8"))) out[key] = value;
2222
+ for (const { key, value } of parseSecretsEnv(readFileSync6(p, "utf8"))) out[key] = value;
2104
2223
  }
2105
2224
  for (const [k, v] of Object.entries(process.env)) {
2106
2225
  if (v !== void 0 && !(k in out)) out[k] = v;
@@ -2111,7 +2230,7 @@ function upsertSecret(cwd, key, value) {
2111
2230
  const dir = resolve7(cwd, SECRETS_DIR);
2112
2231
  mkdirSync4(dir, { recursive: true });
2113
2232
  const p = resolve7(dir, SECRETS_FILE);
2114
- const lines = existsSync8(p) ? readFileSync5(p, "utf8").split("\n") : [];
2233
+ const lines = existsSync8(p) ? readFileSync6(p, "utf8").split("\n") : [];
2115
2234
  const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
2116
2235
  if (idx >= 0) lines[idx] = `${key}=${value}`;
2117
2236
  else {
@@ -2337,8 +2456,59 @@ Next:
2337
2456
  4. greenlight verify <name> --env prod | greenlight doctor`);
2338
2457
  }
2339
2458
 
2459
+ // src/commands/migrations.ts
2460
+ import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2461
+ import { join as join5 } from "path";
2462
+ var DEFAULT_DIR = "supabase/migrations";
2463
+ async function migrationsCommand(args) {
2464
+ if (args[0] !== "scan") {
2465
+ console.log(
2466
+ `usage: greenlight migrations scan [<dir>] [--strict]
2467
+ scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
2468
+ default dir: ${DEFAULT_DIR}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
2469
+ );
2470
+ process.exit(args[0] ? 1 : 0);
2471
+ }
2472
+ const dir = args.slice(1).find((a) => !a.startsWith("-")) ?? DEFAULT_DIR;
2473
+ const strict = args.includes("--strict");
2474
+ let names;
2475
+ try {
2476
+ names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
2477
+ } catch {
2478
+ console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
2479
+ process.exit(0);
2480
+ }
2481
+ if (names.length === 0) {
2482
+ console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
2483
+ process.exit(0);
2484
+ }
2485
+ const files = names.map((f) => ({
2486
+ path: join5(dir, f),
2487
+ content: readFileSync7(join5(dir, f), "utf8")
2488
+ }));
2489
+ const findings = scanSqlFiles(files);
2490
+ if (findings.length === 0) {
2491
+ console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
2492
+ process.exit(0);
2493
+ }
2494
+ for (const f of findings) {
2495
+ console.log(
2496
+ ` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
2497
+ ${f.snippet}`
2498
+ );
2499
+ }
2500
+ const dangers = findings.filter((f) => f.severity === "danger");
2501
+ const blocking = strict ? findings : dangers;
2502
+ const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
2503
+ console.log(
2504
+ `
2505
+ ${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
2506
+ );
2507
+ process.exit(blocking.length === 0 ? 0 : 1);
2508
+ }
2509
+
2340
2510
  // src/commands/preview.ts
2341
- import { execFileSync as execFileSync4, spawn } from "child_process";
2511
+ import { execFileSync as execFileSync5, spawn } from "child_process";
2342
2512
  import { resolve as resolve10 } from "path";
2343
2513
  import { setTimeout as sleep } from "timers/promises";
2344
2514
 
@@ -2377,6 +2547,15 @@ ${report.logs}
2377
2547
  }
2378
2548
  }
2379
2549
  var LOG_TAIL_LINES = 50;
2550
+ function redactSecrets(text, env = process.env) {
2551
+ let out = text;
2552
+ for (const [k, v] of Object.entries(env)) {
2553
+ if (!v || v.length < 6) continue;
2554
+ if (!/TOKEN|KEY|SECRET|PASSWORD|PWD/i.test(k)) continue;
2555
+ out = out.split(v).join("***");
2556
+ }
2557
+ return out;
2558
+ }
2380
2559
  function attachFailureLogs(reports, specs, toolDir) {
2381
2560
  reports.forEach((report, i) => {
2382
2561
  if (report.pass) return;
@@ -2395,7 +2574,7 @@ function attachFailureLogs(reports, specs, toolDir) {
2395
2574
  // Let the command target the exact failing URL without hard-coding it.
2396
2575
  env: { ...process.env, GREENLIGHT_VERIFY_URL: report.url }
2397
2576
  });
2398
- const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd();
2577
+ const out = redactSecrets(`${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd());
2399
2578
  const tail = out.split("\n").slice(-LOG_TAIL_LINES).join("\n");
2400
2579
  report.logs = tail || `(logsOnFailure produced no output${res.error ? `: ${res.error.message}` : ""})`;
2401
2580
  } catch (e) {
@@ -2528,7 +2707,7 @@ async function previewViaDescriptor(entry, name, portOverride) {
2528
2707
  } finally {
2529
2708
  if (pv.teardown) {
2530
2709
  try {
2531
- execFileSync4(pv.teardown, { cwd: toolDir, shell: true, stdio: "inherit" });
2710
+ execFileSync5(pv.teardown, { cwd: toolDir, shell: true, stdio: "inherit" });
2532
2711
  } catch {
2533
2712
  }
2534
2713
  }
@@ -2545,7 +2724,7 @@ async function previewViaBuiltIn(entry, name, portOverride) {
2545
2724
  const plan = servePlan(entry.lane, portOverride);
2546
2725
  if (plan.build) {
2547
2726
  console.log(`build ${name} (${entry.dir})`);
2548
- execFileSync4("pnpm", ["-C", entry.dir, "run", "build"], { stdio: "inherit" });
2727
+ execFileSync5("pnpm", ["-C", entry.dir, "run", "build"], { stdio: "inherit" });
2549
2728
  }
2550
2729
  console.log(`serve ${name} on :${plan.port}`);
2551
2730
  const runArgs = ["-C", entry.dir, "run", plan.script];
@@ -2600,12 +2779,12 @@ async function previewCommand(args) {
2600
2779
  }
2601
2780
 
2602
2781
  // ../packages/loop/src/promote.ts
2603
- import { execFileSync as execFileSync5 } from "child_process";
2782
+ import { execFileSync as execFileSync6 } from "child_process";
2604
2783
  function git(repoDir, args) {
2605
- execFileSync5("git", args, { cwd: repoDir, stdio: "ignore" });
2784
+ execFileSync6("git", args, { cwd: repoDir, stdio: "ignore" });
2606
2785
  }
2607
2786
  function gitOut(repoDir, args) {
2608
- return execFileSync5("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
2787
+ return execFileSync6("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
2609
2788
  }
2610
2789
  function tryRev(repoDir, ref) {
2611
2790
  try {
@@ -2615,9 +2794,16 @@ function tryRev(repoDir, ref) {
2615
2794
  }
2616
2795
  }
2617
2796
  function fetchRefs(repoDir, branches) {
2797
+ try {
2798
+ git(repoDir, ["remote", "get-url", "origin"]);
2799
+ } catch {
2800
+ return { ok: false, hasOrigin: false };
2801
+ }
2618
2802
  try {
2619
2803
  git(repoDir, ["fetch", "--no-tags", "origin", ...branches]);
2804
+ return { ok: true, hasOrigin: true };
2620
2805
  } catch {
2806
+ return { ok: false, hasOrigin: true };
2621
2807
  }
2622
2808
  }
2623
2809
  function resolveRef(repoDir, branch) {
@@ -2644,13 +2830,23 @@ function staleLocalWarnings(repoDir, branches) {
2644
2830
  return warnings;
2645
2831
  }
2646
2832
  function canPromote(repoDir, from = "develop", to = "main") {
2647
- fetchRefs(repoDir, [from, to]);
2833
+ const warnings = [];
2834
+ const fetched = fetchRefs(repoDir, [from, to]);
2835
+ if (fetched.hasOrigin && !fetched.ok) {
2836
+ warnings.push(
2837
+ "could not `git fetch origin` \u2014 eligibility may be based on stale remote-tracking refs (offline / auth?). Re-run after a successful fetch."
2838
+ );
2839
+ }
2648
2840
  const fromRef = resolveRef(repoDir, from);
2649
2841
  const toRef = resolveRef(repoDir, to);
2650
2842
  if (!fromRef || !toRef) {
2651
- return { canPromote: false, reason: `branch "${from}" or "${to}" not found in ${repoDir}` };
2843
+ return {
2844
+ canPromote: false,
2845
+ reason: `branch "${from}" or "${to}" not found in ${repoDir}`,
2846
+ warnings: warnings.length ? warnings : void 0
2847
+ };
2652
2848
  }
2653
- const warnings = staleLocalWarnings(repoDir, [from, to]);
2849
+ warnings.push(...staleLocalWarnings(repoDir, [from, to]));
2654
2850
  try {
2655
2851
  git(repoDir, ["merge-base", "--is-ancestor", toRef, fromRef]);
2656
2852
  return { canPromote: true, reason: `"${to}" can fast-forward to "${from}"`, warnings };
@@ -2716,10 +2912,10 @@ async function promoteCommand(args) {
2716
2912
  }
2717
2913
 
2718
2914
  // src/commands/status.ts
2719
- import { execFileSync as execFileSync6 } from "child_process";
2915
+ import { execFileSync as execFileSync7 } from "child_process";
2720
2916
  function repoSlug(dir) {
2721
2917
  try {
2722
- const url = execFileSync6("git", ["-C", dir, "remote", "get-url", "origin"], {
2918
+ const url = execFileSync7("git", ["-C", dir, "remote", "get-url", "origin"], {
2723
2919
  encoding: "utf8"
2724
2920
  }).trim();
2725
2921
  const m = url.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
@@ -2752,7 +2948,7 @@ function workflowsFor(entry, name, wrapper, toolRepo) {
2752
2948
  }
2753
2949
  function lastRun(repo, workflow) {
2754
2950
  try {
2755
- const out = execFileSync6(
2951
+ const out = execFileSync7(
2756
2952
  "gh",
2757
2953
  [
2758
2954
  "run",
@@ -2808,9 +3004,10 @@ var HELP = `greenlight <command>
2808
3004
  status <name> last ship/deploy/verify run for a tool (via gh)
2809
3005
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
2810
3006
  secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
2811
- agent sync write the loop skill + CLAUDE.md block into this repo
3007
+ agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
2812
3008
  adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
2813
- doctor manifest + repo consistency checks
3009
+ migrations scan [<dir>] [--strict] dangerous-SQL gate for migrations (pre-apply)
3010
+ doctor [--live] consistency checks (--live: DNS + reachability probes)
2814
3011
  help show this message
2815
3012
 
2816
3013
  Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see docs/archive/greenlight-v1.md \xA716.`;
@@ -2845,8 +3042,10 @@ async function main() {
2845
3042
  return agentCommand(args);
2846
3043
  case "adopt":
2847
3044
  return adoptCommand(args);
3045
+ case "migrations":
3046
+ return migrationsCommand(args);
2848
3047
  case "doctor":
2849
- return doctorCommand();
3048
+ return doctorCommand(args);
2850
3049
  default:
2851
3050
  throw new Error(`Unknown command "${cmd}".
2852
3051
 
@@ -131,15 +131,119 @@ function resolveUrl({ domain, name, env, mcp }) {
131
131
  return `https://${host}${mcp ? "/mcp" : ""}`;
132
132
  }
133
133
 
134
+ // ../packages/shared/src/sql-scan.ts
135
+ var RULES = [
136
+ {
137
+ name: "drop-table",
138
+ severity: "danger",
139
+ detail: "DROP TABLE destroys a table and its data",
140
+ test: /\bDROP\s+TABLE\b/i
141
+ },
142
+ {
143
+ name: "drop-column",
144
+ severity: "danger",
145
+ detail: "DROP COLUMN destroys a column and its data",
146
+ test: /\bDROP\s+COLUMN\b/i
147
+ },
148
+ {
149
+ name: "drop-schema",
150
+ severity: "danger",
151
+ detail: "DROP SCHEMA destroys a schema",
152
+ test: /\bDROP\s+SCHEMA\b/i
153
+ },
154
+ {
155
+ name: "drop-database",
156
+ severity: "danger",
157
+ detail: "DROP DATABASE destroys a database",
158
+ test: /\bDROP\s+DATABASE\b/i
159
+ },
160
+ {
161
+ name: "truncate",
162
+ severity: "danger",
163
+ detail: "TRUNCATE empties a table irreversibly",
164
+ test: /\bTRUNCATE\b/i
165
+ },
166
+ {
167
+ name: "delete-without-where",
168
+ severity: "danger",
169
+ detail: "DELETE without WHERE removes every row",
170
+ test: /\bDELETE\s+FROM\b(?![\s\S]*\bWHERE\b)/i
171
+ },
172
+ {
173
+ name: "update-without-where",
174
+ severity: "danger",
175
+ detail: "UPDATE \u2026 SET without WHERE rewrites every row",
176
+ test: /\bUPDATE\s+[^\s;]+\s+SET\b(?![\s\S]*\bWHERE\b)/i
177
+ },
178
+ {
179
+ name: "non-concurrent-index",
180
+ severity: "warn",
181
+ detail: "CREATE INDEX without CONCURRENTLY locks writes (fine on a new/empty table)",
182
+ test: /\bCREATE\s+(?:UNIQUE\s+)?INDEX\b(?![\s\S]*\bCONCURRENTLY\b)/i
183
+ },
184
+ {
185
+ name: "alter-column-type",
186
+ severity: "warn",
187
+ detail: "ALTER COLUMN \u2026 TYPE can rewrite + lock the table",
188
+ test: /\bALTER\s+COLUMN\b[\s\S]*?\bTYPE\b/i
189
+ }
190
+ ];
191
+ var ALLOW = /greenlight:\s*allow/i;
192
+ function scanSql(content, file = "<sql>") {
193
+ const allowLines = /* @__PURE__ */ new Set();
194
+ content.split("\n").forEach((ln, i) => {
195
+ if (ALLOW.test(ln)) allowLines.add(i + 1);
196
+ });
197
+ const stripped = content.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/--[^\n]*/g, (m) => " ".repeat(m.length));
198
+ const findings = [];
199
+ let pos = 0;
200
+ for (const stmt of stripped.split(";")) {
201
+ const lead = stmt.length - stmt.trimStart().length;
202
+ const startLine = content.slice(0, pos + lead).split("\n").length;
203
+ const endLine = content.slice(0, pos + stmt.length).split("\n").length;
204
+ pos += stmt.length + 1;
205
+ if (!stmt.trim()) continue;
206
+ let allowed = false;
207
+ for (let l = startLine; l <= endLine; l++) {
208
+ if (allowLines.has(l)) {
209
+ allowed = true;
210
+ break;
211
+ }
212
+ }
213
+ if (allowed) continue;
214
+ for (const rule of RULES) {
215
+ if (rule.test.test(stmt)) {
216
+ findings.push({
217
+ file,
218
+ line: startLine,
219
+ rule: rule.name,
220
+ severity: rule.severity,
221
+ detail: rule.detail,
222
+ snippet: stmt.replace(/\s+/g, " ").trim().slice(0, 100)
223
+ });
224
+ }
225
+ }
226
+ }
227
+ return findings;
228
+ }
229
+ function scanSqlFiles(files) {
230
+ return files.flatMap((f) => scanSql(f.content, f.path));
231
+ }
232
+
134
233
  // ../packages/verify/src/index.ts
135
234
  import { setTimeout as sleep } from "timers/promises";
136
235
 
137
236
  // ../packages/verify/src/api.ts
138
237
  var trimSlash = (s) => s.replace(/\/+$/, "");
139
- async function checkRoute(base, c) {
238
+ var DEFAULT_TIMEOUT_MS = 1e4;
239
+ var DEFAULT_MAX_LINKS = 50;
240
+ function timedFetch(url, timeoutMs, init) {
241
+ return fetch(url, { redirect: "manual", ...init, signal: AbortSignal.timeout(timeoutMs) });
242
+ }
243
+ async function checkRoute(base, c, timeoutMs) {
140
244
  const name = `GET ${c.path}`;
141
245
  try {
142
- const res = await fetch(base + c.path, { redirect: "manual", headers: c.requestHeaders });
246
+ const res = await timedFetch(base + c.path, timeoutMs, { headers: c.requestHeaders });
143
247
  const reasons = [];
144
248
  if (c.status !== void 0 && res.status !== c.status) {
145
249
  reasons.push(`status ${res.status} != ${c.status}`);
@@ -160,10 +264,10 @@ async function checkRoute(base, c) {
160
264
  return { name, pass: false, detail: msg(e) };
161
265
  }
162
266
  }
163
- async function checkXml(base, candidates, label, marker) {
267
+ async function checkXml(base, candidates, label, marker, timeoutMs) {
164
268
  for (const path of candidates) {
165
269
  try {
166
- const res = await fetch(base + path, { redirect: "manual" });
270
+ const res = await timedFetch(base + path, timeoutMs);
167
271
  if (res.status === 200) {
168
272
  const body = await res.text();
169
273
  const ok = marker.test(body);
@@ -178,65 +282,97 @@ async function checkXml(base, candidates, label, marker) {
178
282
  }
179
283
  return { name: label, pass: false, detail: `none of ${candidates.join(", ")} returned 200` };
180
284
  }
181
- async function checkInternalLinks(base, max = 25) {
285
+ async function checkInternalLinks(base, timeoutMs, max = DEFAULT_MAX_LINKS) {
182
286
  try {
183
- const res = await fetch(`${base}/`, { redirect: "manual" });
287
+ const res = await timedFetch(`${base}/`, timeoutMs);
184
288
  const html = await res.text();
185
289
  const hrefs = /* @__PURE__ */ new Set();
290
+ let capped = false;
186
291
  for (const m of html.matchAll(/href="(\/[^"#?]*)"/g)) {
187
292
  const href = m[1];
188
293
  if (href && !href.startsWith("//")) hrefs.add(href);
189
- if (hrefs.size >= max) break;
294
+ if (hrefs.size >= max) {
295
+ capped = true;
296
+ break;
297
+ }
298
+ }
299
+ if (hrefs.size === 0) {
300
+ return {
301
+ name: "no broken internal links",
302
+ pass: false,
303
+ detail: `no internal links found on ${base}/ (status ${res.status}) \u2014 page empty or unparseable`
304
+ };
190
305
  }
191
306
  const broken = [];
192
307
  for (const href of hrefs) {
193
308
  try {
194
- const r = await fetch(base + href, { redirect: "manual" });
309
+ const r = await timedFetch(base + href, timeoutMs);
195
310
  if (r.status >= 400) broken.push(`${href} (${r.status})`);
196
311
  } catch {
197
312
  broken.push(`${href} (unreachable)`);
198
313
  }
199
314
  }
315
+ const capNote = capped ? `; capped at first ${max} \u2014 raise maxLinks to check more` : "";
200
316
  return {
201
- name: `no broken internal links (${hrefs.size} checked)`,
317
+ name: `no broken internal links (${hrefs.size} checked${capped ? `, capped at ${max}` : ""})`,
202
318
  pass: broken.length === 0,
203
- detail: broken.length ? `broken: ${broken.join(", ")}` : void 0
319
+ detail: broken.length ? `broken: ${broken.join(", ")}${capNote}` : capNote || void 0
204
320
  };
205
321
  } catch (e) {
206
322
  return { name: "no broken internal links", pass: false, detail: msg(e) };
207
323
  }
208
324
  }
209
- async function runChecks(base, spec) {
210
- const checks = [];
211
- for (const c of spec.checks ?? []) checks.push(await checkRoute(base, c));
325
+ function buildTasks(base, spec) {
326
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
327
+ const tasks = [];
328
+ for (const c of spec.checks ?? []) tasks.push(() => checkRoute(base, c, timeoutMs));
212
329
  if (spec.rssValid) {
213
- checks.push(
214
- await checkXml(base, ["/rss.xml", "/feed.xml", "/index.xml"], "rss", /<(rss|feed)[\s>]/i)
330
+ tasks.push(
331
+ () => checkXml(
332
+ base,
333
+ ["/rss.xml", "/feed.xml", "/index.xml"],
334
+ "rss",
335
+ /<(rss|feed)[\s>]/i,
336
+ timeoutMs
337
+ )
215
338
  );
216
339
  }
217
340
  if (spec.sitemapValid) {
218
- checks.push(
219
- await checkXml(
341
+ tasks.push(
342
+ () => checkXml(
220
343
  base,
221
344
  ["/sitemap.xml", "/sitemap-index.xml"],
222
345
  "sitemap",
223
- /<(urlset|sitemapindex)[\s>]/i
346
+ /<(urlset|sitemapindex)[\s>]/i,
347
+ timeoutMs
224
348
  )
225
349
  );
226
350
  }
227
- if (spec.noBrokenInternalLinks) checks.push(await checkInternalLinks(base));
228
- return checks;
351
+ if (spec.noBrokenInternalLinks) {
352
+ tasks.push(() => checkInternalLinks(base, timeoutMs, spec.maxLinks));
353
+ }
354
+ return tasks;
229
355
  }
230
356
  async function verifyApi(baseUrl, spec) {
231
357
  const base = trimSlash(baseUrl);
232
358
  const retries = Math.max(0, spec.settleRetries ?? 0);
233
359
  const delayMs = spec.settleMs ?? 5e3;
234
- let checks = await runChecks(base, spec);
235
- for (let i = 0; i < retries && !checks.every((c) => c.pass); i++) {
360
+ const state = await Promise.all(
361
+ buildTasks(base, spec).map(async (task) => ({ task, check: await task() }))
362
+ );
363
+ for (let i = 0; i < retries && !state.every((s) => s.check.pass); i++) {
236
364
  if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
237
- checks = await runChecks(base, spec);
365
+ await Promise.all(
366
+ state.filter((s) => !s.check.pass).map(async (s) => {
367
+ s.check = await s.task();
368
+ })
369
+ );
238
370
  }
239
- return report("api", baseUrl, checks);
371
+ return report(
372
+ "api",
373
+ baseUrl,
374
+ state.map((s) => s.check)
375
+ );
240
376
  }
241
377
 
242
378
  // ../packages/verify/src/index.ts
@@ -274,11 +410,11 @@ async function verify(baseUrl, spec, opts) {
274
410
  return verifyTest2(spec, opts?.toolDir ?? process.cwd());
275
411
  }
276
412
  case "agent-web": {
277
- const { verifyAgentWeb: verifyAgentWeb2 } = await import("./agent-web-I4LXW4SR.js");
413
+ const { verifyAgentWeb: verifyAgentWeb2 } = await import("./agent-web-3FTO2TLJ.js");
278
414
  return verifyAgentWeb2(baseUrl, spec);
279
415
  }
280
416
  case "eval": {
281
- const { verifyEval: verifyEval2 } = await import("./eval-LLQPOEQX.js");
417
+ const { verifyEval: verifyEval2 } = await import("./eval-44S2BATV.js");
282
418
  return verifyEval2(baseUrl, spec);
283
419
  }
284
420
  }
@@ -302,6 +438,7 @@ export {
302
438
  defineConfig,
303
439
  loadConfig,
304
440
  resolveUrl,
441
+ scanSqlFiles,
305
442
  defineVerify,
306
443
  verifyAll,
307
444
  allPass
@@ -195,7 +195,11 @@ async function verifyAgentWeb(baseUrl, spec) {
195
195
  { name: "@anthropic-ai/sdk available", pass: false, detail: "pnpm add @anthropic-ai/sdk" }
196
196
  ]);
197
197
  }
198
- const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
198
+ const client = new Anthropic({
199
+ apiKey: process.env.ANTHROPIC_API_KEY,
200
+ timeout: 6e4,
201
+ maxRetries: 1
202
+ });
199
203
  let browser;
200
204
  try {
201
205
  browser = await chromium.launch({ headless: !spec.headed });
@@ -19,7 +19,11 @@ function llmJudge(model) {
19
19
  if (!process.env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
20
20
  const sdkName = "@anthropic-ai/sdk";
21
21
  const Anthropic = (await import(sdkName)).default;
22
- const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
22
+ const client = new Anthropic({
23
+ apiKey: process.env.ANTHROPIC_API_KEY,
24
+ timeout: 6e4,
25
+ maxRetries: 1
26
+ });
23
27
  const resp = await client.messages.create({
24
28
  model,
25
29
  max_tokens: 512,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  llmJudge,
3
3
  verifyEval
4
- } from "./chunk-6N7MD6FR.js";
4
+ } from "./chunk-XWTOJHLV.js";
5
5
  import "./chunk-QFKE5JKC.js";
6
6
  export {
7
7
  llmJudge,
package/dist/index.js CHANGED
@@ -2,12 +2,12 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-GO2RVNOP.js";
5
+ } from "./chunk-2A7ZBBYN.js";
6
6
  import "./chunk-HX7VA25D.js";
7
7
  import "./chunk-N3IKUCSF.js";
8
8
  import "./chunk-KP3Y6WRU.js";
9
- import "./chunk-UXHHLEYO.js";
10
- import "./chunk-6N7MD6FR.js";
9
+ import "./chunk-KVOI4UL2.js";
10
+ import "./chunk-XWTOJHLV.js";
11
11
  import "./chunk-QFKE5JKC.js";
12
12
  export {
13
13
  defineConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,10 @@
31
31
  "@anthropic-ai/sdk": "^0.69.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@rtrentjones/greenlight-adapters": "0.2.22",
35
- "@rtrentjones/greenlight-verify": "0.2.22",
36
- "@rtrentjones/greenlight-loop": "0.2.22",
37
- "@rtrentjones/greenlight-shared": "0.2.22"
34
+ "@rtrentjones/greenlight-adapters": "0.2.24",
35
+ "@rtrentjones/greenlight-loop": "0.2.24",
36
+ "@rtrentjones/greenlight-verify": "0.2.24",
37
+ "@rtrentjones/greenlight-shared": "0.2.24"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",