@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.
- package/assets/skills/provider-neon/SKILL.md +49 -0
- package/dist/bin.js +194 -17
- package/dist/{chunk-TFWXR7PP.js → chunk-GPPUZ6Z5.js} +102 -2
- package/dist/index.js +1 -1
- package/package.json +5 -5
|
@@ -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-
|
|
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.
|
|
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}${
|
|
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
|
|
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
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
"
|
|
2129
|
-
|
|
2130
|
-
|
|
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
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.2.
|
|
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-
|
|
35
|
-
"@rtrentjones/greenlight-shared": "0.2.
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
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",
|