@rtrentjones/greenlight 0.2.25 → 0.2.26

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.
@@ -34,11 +34,27 @@ prod and beta hit **different branches**. Pin the provider `kislerdm/neon ~> 0.1
34
34
  Do **not** add a Neon tool to `module.keepalive.targets_json`. Neon resumes on connect — a request
35
35
  just wakes it. (`doctor` does not flag `data: neon` for keepalive; that exemption is intentional.)
36
36
 
37
- ## Migrations
37
+ ## Schema as code / migrations
38
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.
39
+ **Greenlight does NOT run migrations by design.** The split:
40
+ - **Schema** lives in the tool (an ORM Drizzle/Prisma or plain `.sql` migrations).
41
+ - **Branch-per-env**: the TF module owns stable `prod`/`beta`; the **native Neon↔Vercel integration**
42
+ owns ephemeral per-PR preview branches (+ auto-injects `DATABASE_URL`). Don't put ephemeral branches
43
+ in Terraform.
44
+ - **Execution**: the app's own build runs its migrate (`drizzle-kit migrate` / `prisma migrate deploy`)
45
+ against the wired **`DIRECT_URL`** — prod build → prod branch, preview build → preview branch. A
46
+ failed migrate fails the build = a natural gate.
47
+ - **Greenlight's role**: the **dangerous-SQL gate**. Run `greenlight migrations scan` (no `<dir>` →
48
+ it auto-detects `supabase/migrations | migrations | drizzle/migrations | …`) in CI before the migrate.
49
+
50
+ See [docs/migrations.md](../../../docs/migrations.md).
51
+
52
+ ## Sharing one DB + multi-account
53
+
54
+ - **One DB, many services**: a second tool sets `dataShareWith: '<owner>'` (or `add … --share <owner>`)
55
+ — it creates no project and wires the owner's connection strings.
56
+ - **A second Neon account**: `tokenOverrides: { NEON_API_KEY: 'NEON_API_KEY_X' }` → an aliased `neon`
57
+ provider authenticates that account. (A sharer can't also override — it uses the owner's account.)
42
58
 
43
59
  ## MCP
44
60
 
package/dist/bin.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  resolveUrl,
7
7
  scanSqlFiles,
8
8
  verifyAll
9
- } from "./chunk-GPPUZ6Z5.js";
9
+ } from "./chunk-HMU7D7R2.js";
10
10
  import "./chunk-HX7VA25D.js";
11
11
  import "./chunk-N3IKUCSF.js";
12
12
  import "./chunk-KP3Y6WRU.js";
@@ -64,6 +64,7 @@ function serializeTool(t) {
64
64
  const ov = Object.entries(t.tokenOverrides).map(([k, v]) => `${k}: ${q(v)}`).join(", ");
65
65
  parts.push(`tokenOverrides: { ${ov} }`);
66
66
  }
67
+ if (t.dataShareWith !== void 0) parts.push(`dataShareWith: ${q(t.dataShareWith)}`);
67
68
  return ` { ${parts.join(", ")} },`;
68
69
  }
69
70
  function serializeConfig(c) {
@@ -111,7 +112,8 @@ function addTool(config, t) {
111
112
  ...t.port !== void 0 ? { port: t.port } : {},
112
113
  ...t.preview ? { preview: t.preview } : {},
113
114
  ...t.tokens?.length ? { tokens: t.tokens } : {},
114
- ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {}
115
+ ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {},
116
+ ...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {}
115
117
  }
116
118
  ]
117
119
  };
@@ -139,7 +141,8 @@ function upsertTool(config, t) {
139
141
  ...t.port !== void 0 ? { port: t.port } : {},
140
142
  ...t.preview ? { preview: t.preview } : {},
141
143
  ...t.tokens?.length ? { tokens: t.tokens } : {},
142
- ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {}
144
+ ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {},
145
+ ...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {}
143
146
  };
144
147
  const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
145
148
  const result = ConfigSchema.safeParse({ ...config, tools });
@@ -475,7 +478,7 @@ function tokensForTool(tool) {
475
478
  }
476
479
 
477
480
  // src/version.ts
478
- var MODULE_REF = "v0.2.25";
481
+ var MODULE_REF = "v0.2.26";
479
482
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
480
483
  function moduleSource(module, ref = MODULE_REF) {
481
484
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -492,6 +495,8 @@ function emitToolTf(opts) {
492
495
  const useVercel = target === "vercel";
493
496
  const useOci = target === "oci";
494
497
  const supabaseOverride = opts.tokenOverrides?.SUPABASE_ACCESS_TOKEN;
498
+ const neonOverride = opts.tokenOverrides?.NEON_API_KEY;
499
+ const neonOwner = opts.dataShareWith ?? name;
495
500
  const envList = envs.map((e) => `"${e}"`).join(", ");
496
501
  const blocks = [];
497
502
  const assumes = ["var.cloudflare_zone_id"];
@@ -542,18 +547,37 @@ variable "${name}_supabase_database_password" {
542
547
  default = "import-placeholder" # ignored when importing an existing project
543
548
  }${overrideBlock}`);
544
549
  }
545
- if (useNeon) {
550
+ if (useNeon && opts.dataShareWith) {
551
+ blocks.push(`# Shares the Neon project owned by "${opts.dataShareWith}" (one DB, many services).
552
+ # No neon module here \u2014 the env wiring below reads module.${neonOwner}_neon.* (its prod/beta branches).`);
553
+ } else if (useNeon) {
554
+ const providersLine = neonOverride ? `
555
+ providers = { neon = neon.${name} }` : "";
556
+ const overrideBlock = neonOverride ? `
557
+
558
+ # Multi-account: ${name}'s Neon lives in a SECOND account \u2014 an aliased provider authenticates with
559
+ # its own token. In infra.yml: TF_VAR_${name}_neon_api_key: \${{ secrets.${neonOverride} }}
560
+ provider "neon" {
561
+ alias = "${name}"
562
+ api_key = var.${name}_neon_api_key
563
+ }
564
+
565
+ variable "${name}_neon_api_key" {
566
+ type = string
567
+ sensitive = true
568
+ description = "Neon API key for ${name}'s account (scoped secret ${neonOverride})."
569
+ }` : "";
546
570
  blocks.push(`# One Neon project, a branch per env (prod = the project's default branch; beta = a child
547
571
  # branch \u2014 copy-on-write, instant). Compute scales to zero and auto-resumes on the next connection,
548
572
  # so a Neon tool needs NO keepalive (the reason Neon is the default Postgres). NEON_API_KEY configures
549
573
  # the provider in main.tf; the connection strings are module OUTPUTS \u2014 no per-tool secret to gather.
550
574
  module "${name}_neon" {
551
- source = "${moduleSource("neon", ref)}"
575
+ source = "${moduleSource("neon", ref)}"${providersLine}
552
576
 
553
577
  name = "${name}"
554
578
  region = "aws-us-east-1" # Neon region id, e.g. aws-us-east-1 / aws-us-west-2
555
579
  envs = [${envList}]
556
- }`);
580
+ }${overrideBlock}`);
557
581
  }
558
582
  if (useVercel) {
559
583
  const env = useSupabase ? `
@@ -590,10 +614,10 @@ module "${name}_neon" {
590
614
  environment_values = {
591
615
  site_url_prod = "https://${name}.${domain}"
592
616
  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"]
617
+ db_url_prod = module.${neonOwner}_neon.database_url["prod"]
618
+ db_direct_prod = module.${neonOwner}_neon.direct_url["prod"]
619
+ db_url_beta = module.${neonOwner}_neon.database_url["beta"]
620
+ db_direct_beta = module.${neonOwner}_neon.direct_url["beta"]
597
621
  }` : `
598
622
  # No managed data store \u2014 add environment/environment_values if the app needs vars.
599
623
  environment = {}
@@ -1127,7 +1151,7 @@ async function addCommand(args) {
1127
1151
  const name = args[0];
1128
1152
  if (!name || name.startsWith("-")) {
1129
1153
  throw new Error(
1130
- "usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod] [--port 8000]"
1154
+ "usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod] [--port 8000] [--share <owner>]"
1131
1155
  );
1132
1156
  }
1133
1157
  const lane = flag2(args, "--lane");
@@ -1145,7 +1169,9 @@ async function addCommand(args) {
1145
1169
  data: flag2(args, "--data"),
1146
1170
  auth: flag2(args, "--auth"),
1147
1171
  envs: flag2(args, "--envs")?.split(","),
1148
- port: portFlag ? Number(portFlag) : void 0
1172
+ port: portFlag ? Number(portFlag) : void 0,
1173
+ // --share <owner>: this tool reads the owner's Neon DB instead of creating its own (one DB, many services).
1174
+ dataShareWith: flag2(args, "--share")
1149
1175
  });
1150
1176
  const entry = next.tools.find((t) => t.name === name);
1151
1177
  const data = entry?.data ?? "none";
@@ -1194,7 +1220,8 @@ async function addCommand(args) {
1194
1220
  data,
1195
1221
  envs,
1196
1222
  port: entry?.port,
1197
- tokenOverrides: entry?.tokenOverrides
1223
+ tokenOverrides: entry?.tokenOverrides,
1224
+ dataShareWith: entry?.dataShareWith
1198
1225
  })
1199
1226
  );
1200
1227
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
@@ -2527,19 +2554,30 @@ Next:
2527
2554
  }
2528
2555
 
2529
2556
  // src/commands/migrations.ts
2530
- import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2557
+ import { existsSync as existsSync10, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2531
2558
  import { join as join5 } from "path";
2532
2559
  var DEFAULT_DIR = "supabase/migrations";
2560
+ var CANDIDATE_DIRS = [
2561
+ DEFAULT_DIR,
2562
+ "migrations",
2563
+ "drizzle/migrations",
2564
+ "drizzle",
2565
+ "db/migrations"
2566
+ ];
2567
+ function resolveMigrationsDir(explicit, root = process.cwd()) {
2568
+ if (explicit) return explicit;
2569
+ return CANDIDATE_DIRS.find((d) => existsSync10(join5(root, d))) ?? DEFAULT_DIR;
2570
+ }
2533
2571
  async function migrationsCommand(args) {
2534
2572
  if (args[0] !== "scan") {
2535
2573
  console.log(
2536
2574
  `usage: greenlight migrations scan [<dir>] [--strict]
2537
2575
  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\`.`
2576
+ no <dir> \u2192 auto-detects ${CANDIDATE_DIRS.join(" | ")}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
2539
2577
  );
2540
2578
  process.exit(args[0] ? 1 : 0);
2541
2579
  }
2542
- const dir = args.slice(1).find((a) => !a.startsWith("-")) ?? DEFAULT_DIR;
2580
+ const dir = resolveMigrationsDir(args.slice(1).find((a) => !a.startsWith("-")));
2543
2581
  const strict = args.includes("--strict");
2544
2582
  let names;
2545
2583
  try {
@@ -65,7 +65,12 @@ var ToolSchema = z.object({
65
65
  // var to an alternate secret name, so this tool authenticates that provider with a SECOND account
66
66
  // — e.g. { SUPABASE_ACCESS_TOKEN: 'SUPABASE_ACCESS_TOKEN_HEISTMIND' }. Absent ⇒ unchanged (the
67
67
  // default token). `add`/`adopt` emit an aliased provider + scoped var/secret for an overridden token.
68
- tokenOverrides: z.record(z.string(), z.string()).optional()
68
+ tokenOverrides: z.record(z.string(), z.string()).optional(),
69
+ // Share another tool's data store instead of creating one (multiple services on one Neon DB).
70
+ // The value is the OWNER tool's name; this tool emits no data module and wires the owner's
71
+ // connection strings. Cross-tool validity (owner exists, same data, no chains) is checked on
72
+ // the whole config below.
73
+ dataShareWith: z.string().optional()
69
74
  }).superRefine((tool, ctx) => {
70
75
  const rule = MATRIX[tool.lane];
71
76
  if (!rule.targets.includes(tool.target)) {
@@ -89,6 +94,13 @@ var ToolSchema = z.object({
89
94
  message: 'private tools must set auth to "bearer" or "oauth", never "none"'
90
95
  });
91
96
  }
97
+ if (tool.dataShareWith && tool.data !== "neon") {
98
+ ctx.addIssue({
99
+ code: z.ZodIssueCode.custom,
100
+ path: ["dataShareWith"],
101
+ message: 'dataShareWith currently supports data: "neon" only'
102
+ });
103
+ }
92
104
  });
93
105
  var BlogSchema = z.object({
94
106
  lane: z.literal("astro"),
@@ -104,6 +116,35 @@ var ConfigSchema = z.object({
104
116
  // Optional: a tool-only repo (a poly-repo consumer) has no blog.
105
117
  blog: BlogSchema.optional(),
106
118
  tools: z.array(ToolSchema).default([])
119
+ }).superRefine((config, ctx) => {
120
+ for (const [i, tool] of config.tools.entries()) {
121
+ if (!tool.dataShareWith) continue;
122
+ const issue = (message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["tools", i, "dataShareWith"], message });
123
+ if (tool.dataShareWith === tool.name) {
124
+ issue(`"${tool.name}" cannot share a data store with itself`);
125
+ continue;
126
+ }
127
+ const owner = config.tools.find((t) => t.name === tool.dataShareWith);
128
+ if (!owner) {
129
+ issue(`dataShareWith "${tool.dataShareWith}" is not a tool in this manifest`);
130
+ continue;
131
+ }
132
+ if (owner.data !== tool.data) {
133
+ issue(
134
+ `"${tool.name}" (data: ${tool.data}) must share a tool with the same data \u2014 "${owner.name}" is ${owner.data}`
135
+ );
136
+ }
137
+ if (owner.dataShareWith) {
138
+ issue(
139
+ `cannot share with "${owner.name}" \u2014 it is itself a sharer (no chains); point at the owner`
140
+ );
141
+ }
142
+ if (tool.tokenOverrides?.NEON_API_KEY) {
143
+ issue(
144
+ `a sharer uses the owner's Neon account \u2014 remove the NEON_API_KEY override from "${tool.name}"`
145
+ );
146
+ }
147
+ }
107
148
  });
108
149
 
109
150
  // ../packages/shared/src/load.ts
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-GPPUZ6Z5.js";
5
+ } from "./chunk-HMU7D7R2.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.25",
3
+ "version": "0.2.26",
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.25",
35
- "@rtrentjones/greenlight-shared": "0.2.25",
36
- "@rtrentjones/greenlight-verify": "0.2.25",
37
- "@rtrentjones/greenlight-loop": "0.2.25"
34
+ "@rtrentjones/greenlight-adapters": "0.2.26",
35
+ "@rtrentjones/greenlight-loop": "0.2.26",
36
+ "@rtrentjones/greenlight-shared": "0.2.26",
37
+ "@rtrentjones/greenlight-verify": "0.2.26"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",