@rtrentjones/greenlight 0.2.23 → 0.2.25

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.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: provider-neon
3
+ description: How Neon works in a Greenlight setup — the `data: neon` store (serverless Postgres), git-style branch-per-env, scale-to-zero + auto-resume (so NO keepalive, unlike Supabase), pooled vs direct connection strings, the NEON_API_KEY, and migrations on a branch. Use when wiring a tool's database, choosing Neon vs Supabase, or a Neon apply.
4
+ ---
5
+
6
+ # provider-neon
7
+
8
+ `data: neon` is the **default Postgres** — for tools that need a SQL database and nothing else
9
+ bundled. One Neon **project** per tool, **a branch per env** (git-style copy-on-write): `prod` is
10
+ the project's default branch; `beta` is a child branch (separate data, instant to create). Compute
11
+ **autosuspends and auto-resumes on the next connection**, so a Neon tool needs **no keepalive** —
12
+ that's the whole reason Neon is preferred over Supabase, which pauses for 7 days and needs a manual
13
+ unpause. Choose `supabase` only when you need bundled auth + storage + realtime together.
14
+
15
+ ## Token — `NEON_API_KEY`
16
+
17
+ Console → Account settings → API keys. Account-level (configures the `neon` provider for every Neon
18
+ tool, like `CLOUDFLARE_API_TOKEN`) — **not** per-tool. `greenlight add` verifies it against
19
+ `/api/v2/projects` (HTTP 200). There is **no per-tool secret**: the role/password/connection strings
20
+ are module OUTPUTS, not inputs.
21
+
22
+ ## Terraform module — `infra/modules/neon`
23
+
24
+ Creates the project (default branch = prod) + a `neon_branch` per non-prod env (except `preview`,
25
+ which is ephemeral/per-PR — created by CI, not Terraform). Outputs two per-env maps:
26
+ - **`database_url[env]`** — the **pooled** (pgbouncer) string → `DATABASE_URL` for the serverless app.
27
+ - **`direct_url[env]`** — the **direct** string → `DIRECT_URL` for migrations.
28
+
29
+ The emitted `<name>.tf` wires `database_url["prod"]`/`["beta"]` into the Vercel env per target, so
30
+ prod and beta hit **different branches**. Pin the provider `kislerdm/neon ~> 0.13`.
31
+
32
+ ## No keepalive
33
+
34
+ Do **not** add a Neon tool to `module.keepalive.targets_json`. Neon resumes on connect — a request
35
+ just wakes it. (`doctor` does not flag `data: neon` for keepalive; that exemption is intentional.)
36
+
37
+ ## Migrations
38
+
39
+ Run migrations against the env's **branch** (`DIRECT_URL`). A PR's ephemeral branch is the safe place
40
+ to test a migration before it touches prod. Gate them with `greenlight migrations scan` (the
41
+ dangerous-SQL pre-apply check) in the tool's CI.
42
+
43
+ ## MCP
44
+
45
+ `.mcp.json` wires `neon` (hosted) with `Authorization: Bearer ${NEON_API_KEY}`. Run `/mcp` to auth.
46
+
47
+ ## Rule
48
+ The **blog must never use a database** that can pause — but Neon's auto-resume makes it safe for
49
+ *tools*; still, the apex blog stays `data: none` (D1/KV/external only). Neon is per-tool Postgres.
package/dist/bin.js CHANGED
@@ -4,8 +4,9 @@ import {
4
4
  allPass,
5
5
  loadConfig,
6
6
  resolveUrl,
7
+ scanSqlFiles,
7
8
  verifyAll
8
- } from "./chunk-TFWXR7PP.js";
9
+ } from "./chunk-GPPUZ6Z5.js";
9
10
  import "./chunk-HX7VA25D.js";
10
11
  import "./chunk-N3IKUCSF.js";
11
12
  import "./chunk-KP3Y6WRU.js";
@@ -316,6 +317,37 @@ var PACKS = [
316
317
  skill: "provider-supabase",
317
318
  tfModules: ["supabase"]
318
319
  },
320
+ {
321
+ id: "neon",
322
+ name: "Neon",
323
+ appliesTo: (t) => t.data === "neon",
324
+ guide: "docs/provider-tokens.md \u2014 NEON_API_KEY (project + branch management)",
325
+ setupUrl: "https://console.neon.tech/app/settings/api-keys",
326
+ tokens: [
327
+ {
328
+ // Shared account credential (configures the neon TF provider, like CLOUDFLARE_API_TOKEN) —
329
+ // NOT per-tool. The role password + connection string are module OUTPUTS, not inputs, so
330
+ // (unlike Supabase) there is no per-tool password variable to gather.
331
+ envVar: "NEON_API_KEY",
332
+ label: "Neon API key (project + branch management)",
333
+ verify: async (t) => {
334
+ const r = await fetch("https://console.neon.tech/api/v2/projects", {
335
+ headers: { Authorization: `Bearer ${t}` }
336
+ });
337
+ return { ok: okStatus(r), detail: `HTTP ${r.status}` };
338
+ }
339
+ }
340
+ ],
341
+ mcp: {
342
+ neon: {
343
+ type: "http",
344
+ url: "https://mcp.neon.tech/mcp",
345
+ headers: { Authorization: "Bearer ${NEON_API_KEY}" }
346
+ }
347
+ },
348
+ skill: "provider-neon",
349
+ tfModules: ["neon"]
350
+ },
319
351
  {
320
352
  id: "hcp",
321
353
  name: "HCP Terraform (remote state)",
@@ -443,7 +475,7 @@ function tokensForTool(tool) {
443
475
  }
444
476
 
445
477
  // src/version.ts
446
- var MODULE_REF = "v0.2.23";
478
+ var MODULE_REF = "v0.2.25";
447
479
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
448
480
  function moduleSource(module, ref = MODULE_REF) {
449
481
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -456,6 +488,7 @@ function emitToolTf(opts) {
456
488
  const port = opts.port ?? 8e3;
457
489
  const slug = opts.slug ?? `OWNER/${name}`;
458
490
  const useSupabase = data === "supabase";
491
+ const useNeon = data === "neon";
459
492
  const useVercel = target === "vercel";
460
493
  const useOci = target === "oci";
461
494
  const supabaseOverride = opts.tokenOverrides?.SUPABASE_ACCESS_TOKEN;
@@ -466,9 +499,9 @@ function emitToolTf(opts) {
466
499
  if (useSupabase) assumes.push("var.supabase_organization_id");
467
500
  const ghcrOwner = (slug.split("/")[0] ?? "owner").toLowerCase();
468
501
  blocks.push(
469
- `# ${name} \u2014 ${lane}/${target}${useSupabase ? "/supabase" : ""}, emitted by \`greenlight add\`.
502
+ `# ${name} \u2014 ${lane}/${target}${data && data !== "none" ? `/${data}` : ""}, emitted by \`greenlight add\`.
470
503
  # Review, then commit + push: the wrapper's infra.yml (HCP-backed) runs \`terraform apply\`.
471
- # Assumes infra/main.tf declares: ${[useVercel && "vercel", useSupabase && "supabase", useOci && "oci"].filter(Boolean).join(" + ") || "cloudflare + github"} provider(s)
504
+ # Assumes infra/main.tf declares: ${[useVercel && "vercel", useSupabase && "supabase", useNeon && "neon", useOci && "oci"].filter(Boolean).join(" + ") || "cloudflare + github"} provider(s)
472
505
  # and the variables ${assumes.join(", ")}.${opts.external ? `
473
506
  # External tool: app code + deploy live in ${slug}; this manages only its infra here.` : ""}`
474
507
  );
@@ -508,6 +541,19 @@ variable "${name}_supabase_database_password" {
508
541
  sensitive = true
509
542
  default = "import-placeholder" # ignored when importing an existing project
510
543
  }${overrideBlock}`);
544
+ }
545
+ if (useNeon) {
546
+ blocks.push(`# One Neon project, a branch per env (prod = the project's default branch; beta = a child
547
+ # branch \u2014 copy-on-write, instant). Compute scales to zero and auto-resumes on the next connection,
548
+ # so a Neon tool needs NO keepalive (the reason Neon is the default Postgres). NEON_API_KEY configures
549
+ # the provider in main.tf; the connection strings are module OUTPUTS \u2014 no per-tool secret to gather.
550
+ module "${name}_neon" {
551
+ source = "${moduleSource("neon", ref)}"
552
+
553
+ name = "${name}"
554
+ region = "aws-us-east-1" # Neon region id, e.g. aws-us-east-1 / aws-us-west-2
555
+ envs = [${envList}]
556
+ }`);
511
557
  }
512
558
  if (useVercel) {
513
559
  const env = useSupabase ? `
@@ -530,6 +576,24 @@ variable "${name}_supabase_database_password" {
530
576
  supa_url_beta = module.${name}_supabase.url
531
577
  supa_anon_beta = module.${name}_supabase.anon_key
532
578
  supa_service_beta = module.${name}_supabase.service_role_key
579
+ }` : useNeon ? `
580
+ environment = {
581
+ site_url_prod = { key = "SITE_URL", target = ["production"], sensitive = false }
582
+ site_url_beta = { key = "SITE_URL", target = ["preview"], sensitive = false }
583
+ db_url_prod = { key = "DATABASE_URL", target = ["production"], sensitive = true }
584
+ db_direct_prod = { key = "DIRECT_URL", target = ["production"], sensitive = true }
585
+ db_url_beta = { key = "DATABASE_URL", target = ["preview"], sensitive = true }
586
+ db_direct_beta = { key = "DIRECT_URL", target = ["preview"], sensitive = true }
587
+ }
588
+ # Pooled (DATABASE_URL) for the serverless app; direct (DIRECT_URL) for migrations. Prod hits the
589
+ # project's default branch; beta hits the "beta" branch \u2014 separate data, instant copy-on-write.
590
+ environment_values = {
591
+ site_url_prod = "https://${name}.${domain}"
592
+ site_url_beta = "https://beta.${name}.${domain}"
593
+ db_url_prod = module.${name}_neon.database_url["prod"]
594
+ db_direct_prod = module.${name}_neon.direct_url["prod"]
595
+ db_url_beta = module.${name}_neon.database_url["beta"]
596
+ db_direct_beta = module.${name}_neon.direct_url["beta"]
533
597
  }` : `
534
598
  # No managed data store \u2014 add environment/environment_values if the app needs vars.
535
599
  environment = {}
@@ -639,10 +703,13 @@ function emitWrapperMainTf(opts) {
639
703
  req.push(' vercel = { source = "vercel/vercel", version = "~> 3.0" }');
640
704
  if (need.has("supabase"))
641
705
  req.push(' supabase = { source = "supabase/supabase", version = "~> 1.0" }');
706
+ if (need.has("neon"))
707
+ req.push(' neon = { source = "kislerdm/neon", version = "~> 0.13" }');
642
708
  if (need.has("oci")) req.push(' oci = { source = "oracle/oci", version = ">= 5.0" }');
643
709
  const providerBlocks = ['provider "cloudflare" {}', `provider "github" { owner = "${owner}" }`];
644
710
  if (need.has("vercel")) providerBlocks.push('provider "vercel" {}');
645
711
  if (need.has("supabase")) providerBlocks.push('provider "supabase" {}');
712
+ if (need.has("neon")) providerBlocks.push('provider "neon" { api_key = var.neon_api_key }');
646
713
  if (need.has("oci")) {
647
714
  providerBlocks.push(`provider "oci" {
648
715
  # trimspace guards against a trailing newline/space in a pasted secret (a malformed region
@@ -659,6 +726,9 @@ function emitWrapperMainTf(opts) {
659
726
  if (need.has("supabase")) {
660
727
  vars.push('variable "supabase_organization_id" { type = string }');
661
728
  }
729
+ if (need.has("neon")) {
730
+ vars.push('variable "neon_api_key" {\n type = string\n sensitive = true\n}');
731
+ }
662
732
  if (need.has("oci")) {
663
733
  vars.push('variable "oci_tenancy_ocid" { type = string }');
664
734
  vars.push('variable "oci_user_ocid" { type = string }');
@@ -702,6 +772,7 @@ function providersForTool(tool) {
702
772
  const out = ["cloudflare", "github"];
703
773
  if (ids.has("vercel")) out.push("vercel");
704
774
  if (ids.has("supabase")) out.push("supabase");
775
+ if (ids.has("neon")) out.push("neon");
705
776
  if (ids.has("oci")) out.push("oci");
706
777
  return out;
707
778
  }
@@ -777,10 +848,22 @@ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
777
848
  async function agentCommand(args) {
778
849
  if (args[0] !== "sync") {
779
850
  console.log(
780
- "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
851
+ "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
852
  );
782
853
  process.exit(args[0] ? 1 : 0);
783
854
  }
855
+ const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
856
+ if (name) {
857
+ const { config } = await loadManifest();
858
+ const entry = resolveEntry(config, name);
859
+ const dir = resolve3(process.cwd(), entry.dir ?? ".");
860
+ materializeAgentKit(dir, { target: entry.target, data: entry.data });
861
+ console.log(
862
+ `
863
+ Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (target=${entry.target}, data=${entry.data}).`
864
+ );
865
+ return;
866
+ }
784
867
  materializeAgentKit(process.cwd());
785
868
  console.log(
786
869
  "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
@@ -1992,6 +2075,7 @@ async function deployCommand(args) {
1992
2075
 
1993
2076
  // src/commands/doctor.ts
1994
2077
  import { execFileSync as execFileSync4 } from "child_process";
2078
+ import { lookup } from "dns/promises";
1995
2079
  import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
1996
2080
  import { join as join4 } from "path";
1997
2081
  function dirCheck(label, dir) {
@@ -2120,19 +2204,52 @@ function runDoctor(config, root) {
2120
2204
  });
2121
2205
  checks.push(versionDriftCheck(root));
2122
2206
  checks.push(submoduleDriftCheck(root));
2123
- for (const name of [
2124
- "DNS propagation",
2125
- "terraform drift",
2126
- "Vercel cap headroom",
2127
- "keepalive health (live)",
2128
- "OCI PAYG status"
2129
- ]) {
2130
- checks.push({ name, status: "skip", detail: "needs provider creds / packages (Phase 5/7/8)" });
2207
+ return checks;
2208
+ }
2209
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
2210
+ async function livenessCheck(name, url) {
2211
+ try {
2212
+ const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(1e4) });
2213
+ 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}` };
2214
+ } catch (e) {
2215
+ return { name: `${name}: live`, status: "fail", detail: `${url} unreachable: ${errMsg(e)}` };
2216
+ }
2217
+ }
2218
+ async function dnsCheck(name, url) {
2219
+ const host = new URL(url).hostname;
2220
+ try {
2221
+ await lookup(host);
2222
+ return { name: `${name}: DNS`, status: "ok", detail: host };
2223
+ } catch {
2224
+ return { name: `${name}: DNS`, status: "fail", detail: `${host} does not resolve` };
2225
+ }
2226
+ }
2227
+ async function runDoctorLive(config) {
2228
+ const targets = [];
2229
+ if (config.blog) targets.push({ name: "blog", url: `https://${config.domain}` });
2230
+ for (const t of config.tools) {
2231
+ if (!t.envs?.includes("prod")) continue;
2232
+ targets.push({
2233
+ name: t.name,
2234
+ url: resolveUrl({ domain: config.domain, name: t.name, env: "prod", mcp: t.lane === "mcp" })
2235
+ });
2236
+ }
2237
+ const checks = [];
2238
+ for (const tg of targets) {
2239
+ checks.push(await dnsCheck(tg.name, tg.url));
2240
+ checks.push(await livenessCheck(tg.name, tg.url));
2241
+ }
2242
+ for (const name of ["terraform drift", "Vercel cap headroom", "OCI PAYG status"]) {
2243
+ checks.push({
2244
+ name,
2245
+ status: "skip",
2246
+ detail: "not yet implemented \u2014 needs the provider API + creds"
2247
+ });
2131
2248
  }
2132
2249
  return checks;
2133
2250
  }
2134
2251
  var ICON = { ok: "\u2714", warn: "!", fail: "\u2718", skip: "\xB7" };
2135
- async function doctorCommand() {
2252
+ async function doctorCommand(args = []) {
2136
2253
  let config;
2137
2254
  try {
2138
2255
  ({ config } = await loadManifest());
@@ -2141,13 +2258,19 @@ async function doctorCommand() {
2141
2258
  console.error(`\u2718 manifest: ${e instanceof Error ? e.message : String(e)}`);
2142
2259
  process.exit(1);
2143
2260
  }
2261
+ const live = args.includes("--live");
2144
2262
  const checks = runDoctor(config, process.cwd());
2263
+ if (live) {
2264
+ console.log(" (probing live prod URLs\u2026)");
2265
+ checks.push(...await runDoctorLive(config));
2266
+ }
2145
2267
  for (const c of checks) {
2146
2268
  console.log(` ${ICON[c.status]} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
2147
2269
  }
2148
2270
  const failed = checks.filter((c) => c.status === "fail").length;
2149
2271
  console.log(`
2150
2272
  ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2273
+ if (!live) console.log("\xB7 run `greenlight doctor --live` for DNS + reachability probes");
2151
2274
  process.exit(failed === 0 ? 0 : 1);
2152
2275
  }
2153
2276
 
@@ -2403,6 +2526,57 @@ Next:
2403
2526
  4. greenlight verify <name> --env prod | greenlight doctor`);
2404
2527
  }
2405
2528
 
2529
+ // src/commands/migrations.ts
2530
+ import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2531
+ import { join as join5 } from "path";
2532
+ var DEFAULT_DIR = "supabase/migrations";
2533
+ async function migrationsCommand(args) {
2534
+ if (args[0] !== "scan") {
2535
+ console.log(
2536
+ `usage: greenlight migrations scan [<dir>] [--strict]
2537
+ scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
2538
+ default dir: ${DEFAULT_DIR}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
2539
+ );
2540
+ process.exit(args[0] ? 1 : 0);
2541
+ }
2542
+ const dir = args.slice(1).find((a) => !a.startsWith("-")) ?? DEFAULT_DIR;
2543
+ const strict = args.includes("--strict");
2544
+ let names;
2545
+ try {
2546
+ names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
2547
+ } catch {
2548
+ console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
2549
+ process.exit(0);
2550
+ }
2551
+ if (names.length === 0) {
2552
+ console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
2553
+ process.exit(0);
2554
+ }
2555
+ const files = names.map((f) => ({
2556
+ path: join5(dir, f),
2557
+ content: readFileSync7(join5(dir, f), "utf8")
2558
+ }));
2559
+ const findings = scanSqlFiles(files);
2560
+ if (findings.length === 0) {
2561
+ console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
2562
+ process.exit(0);
2563
+ }
2564
+ for (const f of findings) {
2565
+ console.log(
2566
+ ` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
2567
+ ${f.snippet}`
2568
+ );
2569
+ }
2570
+ const dangers = findings.filter((f) => f.severity === "danger");
2571
+ const blocking = strict ? findings : dangers;
2572
+ const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
2573
+ console.log(
2574
+ `
2575
+ ${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
2576
+ );
2577
+ process.exit(blocking.length === 0 ? 0 : 1);
2578
+ }
2579
+
2406
2580
  // src/commands/preview.ts
2407
2581
  import { execFileSync as execFileSync5, spawn } from "child_process";
2408
2582
  import { resolve as resolve10 } from "path";
@@ -2900,9 +3074,10 @@ var HELP = `greenlight <command>
2900
3074
  status <name> last ship/deploy/verify run for a tool (via gh)
2901
3075
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
2902
3076
  secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
2903
- agent sync write the loop skill + CLAUDE.md block into this repo
3077
+ agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
2904
3078
  adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
2905
- doctor manifest + repo consistency checks
3079
+ migrations scan [<dir>] [--strict] dangerous-SQL gate for migrations (pre-apply)
3080
+ doctor [--live] consistency checks (--live: DNS + reachability probes)
2906
3081
  help show this message
2907
3082
 
2908
3083
  Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see docs/archive/greenlight-v1.md \xA716.`;
@@ -2937,8 +3112,10 @@ async function main() {
2937
3112
  return agentCommand(args);
2938
3113
  case "adopt":
2939
3114
  return adoptCommand(args);
3115
+ case "migrations":
3116
+ return migrationsCommand(args);
2940
3117
  case "doctor":
2941
- return doctorCommand();
3118
+ return doctorCommand(args);
2942
3119
  default:
2943
3120
  throw new Error(`Unknown command "${cmd}".
2944
3121
 
@@ -15,13 +15,13 @@ import { createJiti } from "jiti";
15
15
  import { z } from "zod";
16
16
  var LaneEnum = z.enum(["astro", "next", "mcp"]);
17
17
  var TargetEnum = z.enum(["workers", "vercel", "oci"]);
18
- var DataEnum = z.enum(["none", "d1", "kv", "supabase"]);
18
+ var DataEnum = z.enum(["none", "d1", "kv", "supabase", "neon"]);
19
19
  var AuthEnum = z.enum(["none", "bearer", "oauth"]);
20
20
  var AccessEnum = z.enum(["public", "private"]);
21
21
  var EnvEnum = z.enum(["preview", "beta", "prod"]);
22
22
  var MATRIX = {
23
23
  astro: { targets: ["workers"], data: ["none", "d1", "kv"] },
24
- next: { targets: ["vercel"], data: ["none", "supabase"] },
24
+ next: { targets: ["vercel"], data: ["none", "supabase", "neon"] },
25
25
  mcp: { targets: ["workers", "oci"], data: ["none"] }
26
26
  };
27
27
  var ToolSchema = z.object({
@@ -131,6 +131,105 @@ 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
 
@@ -339,6 +438,7 @@ export {
339
438
  defineConfig,
340
439
  loadConfig,
341
440
  resolveUrl,
441
+ scanSqlFiles,
342
442
  defineVerify,
343
443
  verifyAll,
344
444
  allPass
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-TFWXR7PP.js";
5
+ } from "./chunk-GPPUZ6Z5.js";
6
6
  import "./chunk-HX7VA25D.js";
7
7
  import "./chunk-N3IKUCSF.js";
8
8
  import "./chunk-KP3Y6WRU.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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-loop": "0.2.23",
35
- "@rtrentjones/greenlight-shared": "0.2.23",
36
- "@rtrentjones/greenlight-adapters": "0.2.23",
37
- "@rtrentjones/greenlight-verify": "0.2.23"
34
+ "@rtrentjones/greenlight-adapters": "0.2.25",
35
+ "@rtrentjones/greenlight-shared": "0.2.25",
36
+ "@rtrentjones/greenlight-verify": "0.2.25",
37
+ "@rtrentjones/greenlight-loop": "0.2.25"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",