@rtrentjones/greenlight 0.2.19 → 0.2.21

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/README.md CHANGED
@@ -48,7 +48,8 @@ greenlight <command>
48
48
  | `verify <name> --env <beta\|prod>` (or `--url`) | run the shared verify harness |
49
49
  | `promote <name>` | gated `develop → main` fast-forward (after beta verify) |
50
50
  | `deploy <name>` | target deploy hook (e.g. OCI restart = re-pull) |
51
- | `doctor` / `config` | health checks / load + validate + print the manifest |
51
+ | `status <name>` | the ship deploy verify run chain across repos |
52
+ | `doctor` / `config` | health checks (incl. token-scoping conformance) / load + validate + print the manifest |
52
53
 
53
54
  ## The loop
54
55
 
@@ -70,7 +71,8 @@ import { defineConfig, defineVerify } from '@rtrentjones/greenlight';
70
71
 
71
72
  export default defineConfig({
72
73
  domain: 'you.dev',
73
- tools: { notes: { lane: 'mcp', target: 'oci', data: 'none', auth: 'bearer' } },
74
+ alerts: { sink: 'github-issue' },
75
+ tools: [{ name: 'notes', lane: 'mcp', target: 'oci', data: 'none', auth: 'bearer', envs: ['prod'] }],
74
76
  });
75
77
  ```
76
78
 
@@ -80,6 +82,7 @@ Also exported: `loadConfig`, and the `GreenlightConfig` / `VerifySpec` types.
80
82
 
81
83
  - **Repo + full docs:** <https://github.com/RTrentJones/greenlight>
82
84
  - **Architecture:** [docs/architecture.md](https://github.com/RTrentJones/greenlight/blob/main/docs/architecture.md)
83
- - **Spec:** [greenlight-v1.md](https://github.com/RTrentJones/greenlight/blob/main/greenlight-v1.md)
85
+ - **Spec:** [greenlight-v2.md](https://github.com/RTrentJones/greenlight/blob/main/greenlight-v2.md)
86
+ - **Add a provider type:** [docs/adding-a-provider.md](https://github.com/RTrentJones/greenlight/blob/main/docs/adding-a-provider.md)
84
87
 
85
88
  MIT
@@ -42,5 +42,5 @@ Migrate local → HCP with a plain `terraform init` (answer `yes` to copy state)
42
42
  plan -out → apply. This is the deploy half — the CLI only edits the `.tf`; CI applies.
43
43
 
44
44
  ## Alternatives
45
- See `docs/terraform-state-r2.md` for the full backend chooser (HCP no-CC · OCI S3-compat ·
45
+ See `docs/terraform-state.md` for the full backend chooser (HCP no-CC · OCI S3-compat ·
46
46
  R2 card-required · AWS · local).
package/dist/bin.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  resolveUrl,
7
7
  verifyAll
8
- } from "./chunk-6USV5AQV.js";
8
+ } from "./chunk-GO2RVNOP.js";
9
9
  import "./chunk-HX7VA25D.js";
10
10
  import "./chunk-N3IKUCSF.js";
11
11
  import "./chunk-KP3Y6WRU.js";
@@ -58,6 +58,11 @@ function serializeTool(t) {
58
58
  if (pv.path !== void 0) pvParts.push(`path: ${q(pv.path)}`);
59
59
  parts.push(`preview: { ${pvParts.join(", ")} }`);
60
60
  }
61
+ if (t.tokens?.length) parts.push(`tokens: [${t.tokens.map(q).join(", ")}]`);
62
+ if (t.tokenOverrides && Object.keys(t.tokenOverrides).length) {
63
+ const ov = Object.entries(t.tokenOverrides).map(([k, v]) => `${k}: ${q(v)}`).join(", ");
64
+ parts.push(`tokenOverrides: { ${ov} }`);
65
+ }
61
66
  return ` { ${parts.join(", ")} },`;
62
67
  }
63
68
  function serializeConfig(c) {
@@ -103,7 +108,9 @@ function addTool(config, t) {
103
108
  ...t.adopted ? { adopted: true } : {},
104
109
  ...t.external ? { external: true } : {},
105
110
  ...t.port !== void 0 ? { port: t.port } : {},
106
- ...t.preview ? { preview: t.preview } : {}
111
+ ...t.preview ? { preview: t.preview } : {},
112
+ ...t.tokens?.length ? { tokens: t.tokens } : {},
113
+ ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {}
107
114
  }
108
115
  ]
109
116
  };
@@ -129,7 +136,9 @@ function upsertTool(config, t) {
129
136
  ...t.adopted ? { adopted: true } : {},
130
137
  ...t.external ? { external: true } : {},
131
138
  ...t.port !== void 0 ? { port: t.port } : {},
132
- ...t.preview ? { preview: t.preview } : {}
139
+ ...t.preview ? { preview: t.preview } : {},
140
+ ...t.tokens?.length ? { tokens: t.tokens } : {},
141
+ ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {}
133
142
  };
134
143
  const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
135
144
  const result = ConfigSchema.safeParse({ ...config, tools });
@@ -186,7 +195,9 @@ function resolveEntry(config, name) {
186
195
  dir: tool.dir ?? `tools/${tool.name}`,
187
196
  external: tool.external,
188
197
  port: tool.port,
189
- preview: tool.preview
198
+ preview: tool.preview,
199
+ tokens: tool.tokens,
200
+ tokenOverrides: tool.tokenOverrides
190
201
  };
191
202
  }
192
203
  var VERIFY_MODES = /* @__PURE__ */ new Set(["api", "mcp", "playwright", "test", "agent-web", "eval"]);
@@ -311,7 +322,7 @@ var PACKS = [
311
322
  always: true,
312
323
  // remote state backs every wrapper's infra
313
324
  appliesTo: () => true,
314
- guide: "docs/terraform-state-r2.md \u2014 HCP Terraform free tier (no credit card)",
325
+ guide: "docs/terraform-state.md \u2014 HCP Terraform free tier (no credit card)",
315
326
  setupUrl: "https://app.terraform.io/app/settings/tokens",
316
327
  tokens: [
317
328
  {
@@ -401,6 +412,12 @@ var PACKS = [
401
412
  tfModules: ["tool", "tunnel", "oci-network", "oci-container-instance"]
402
413
  }
403
414
  ];
415
+ function secretKeyFor(tok, toolName, overrides) {
416
+ const override = overrides?.[tok.envVar];
417
+ if (override) return override;
418
+ const suffix = `_${toolName.toUpperCase().replace(/-/g, "_")}`;
419
+ return tok.envVar.toUpperCase() + (tok.perTool ? suffix : "");
420
+ }
404
421
  function packsForTool(tool) {
405
422
  return PACKS.filter((p) => p.always || (tool ? p.appliesTo(tool) : false));
406
423
  }
@@ -426,7 +443,7 @@ function tokensForTool(tool) {
426
443
  }
427
444
 
428
445
  // src/version.ts
429
- var MODULE_REF = "v0.2.19";
446
+ var MODULE_REF = "v0.2.21";
430
447
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
431
448
  function moduleSource(module, ref = MODULE_REF) {
432
449
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -441,6 +458,7 @@ function emitToolTf(opts) {
441
458
  const useSupabase = data === "supabase";
442
459
  const useVercel = target === "vercel";
443
460
  const useOci = target === "oci";
461
+ const supabaseOverride = opts.tokenOverrides?.SUPABASE_ACCESS_TOKEN;
444
462
  const envList = envs.map((e) => `"${e}"`).join(", ");
445
463
  const blocks = [];
446
464
  const assumes = ["var.cloudflare_zone_id"];
@@ -455,9 +473,25 @@ function emitToolTf(opts) {
455
473
  # External tool: app code + deploy live in ${slug}; this manages only its infra here.` : ""}`
456
474
  );
457
475
  if (useSupabase) {
476
+ const providersLine = supabaseOverride ? `
477
+ providers = { supabase = supabase.${name} }` : "";
478
+ const overrideBlock = supabaseOverride ? `
479
+
480
+ # Multi-account: ${name}'s Supabase lives in a SECOND account \u2014 an aliased provider authenticates
481
+ # with its own token. In infra.yml: TF_VAR_${name}_supabase_access_token: \${{ secrets.${supabaseOverride} }}
482
+ provider "supabase" {
483
+ alias = "${name}"
484
+ access_token = var.${name}_supabase_access_token
485
+ }
486
+
487
+ variable "${name}_supabase_access_token" {
488
+ type = string
489
+ sensitive = true
490
+ description = "Supabase Management API token for ${name}'s account (scoped secret ${supabaseOverride})."
491
+ }` : "";
458
492
  blocks.push(`# One Supabase project (schema-per-env), kept declarative + recreatable + kept alive.
459
493
  module "${name}_supabase" {
460
- source = "${moduleSource("supabase", ref)}"
494
+ source = "${moduleSource("supabase", ref)}"${providersLine}
461
495
 
462
496
  name = "${name}"
463
497
  project_name = "${name}-db"
@@ -473,7 +507,7 @@ variable "${name}_supabase_database_password" {
473
507
  type = string
474
508
  sensitive = true
475
509
  default = "import-placeholder" # ignored when importing an existing project
476
- }`);
510
+ }${overrideBlock}`);
477
511
  }
478
512
  if (useVercel) {
479
513
  const env = useSupabase ? `
@@ -643,7 +677,7 @@ locals {
643
677
  ` : "";
644
678
  return `# Wrapper infra (singleton): providers + remote-state backend + shared variables.
645
679
  # \`greenlight add\` appends per-tool module blocks as infra/<name>.tf. Apply is CI/CD's job
646
- # (infra.yml). Fill in the HCP backend below before the first apply (docs/terraform-state-r2.md).
680
+ # (infra.yml). Fill in the HCP backend below before the first apply (docs/terraform-state.md).
647
681
 
648
682
  terraform {
649
683
  required_version = ">= 1.7"
@@ -918,8 +952,7 @@ async function gatherSecrets(name, repo, env, prefill) {
918
952
  for (const pack of packs) {
919
953
  console.log(`\u2500\u2500 ${pack.name}${pack.setupUrl ? ` \u2192 ${pack.setupUrl}` : ""}`);
920
954
  for (const tok of pack.tokens) {
921
- const suffix = `_${name.toUpperCase().replace(/-/g, "_")}`;
922
- const key = tok.envVar.toUpperCase() + (tok.perTool ? suffix : "");
955
+ const key = secretKeyFor(tok, name, entry.tokenOverrides);
923
956
  if (key === "GITHUB_TOKEN") {
924
957
  console.log(" \xB7 GITHUB_TOKEN \u2014 provided automatically by Actions; skipping");
925
958
  continue;
@@ -1070,7 +1103,16 @@ async function addCommand(args) {
1070
1103
  } else {
1071
1104
  writeFileSync2(
1072
1105
  toolTf,
1073
- emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
1106
+ emitToolTf({
1107
+ name,
1108
+ domain: config.domain,
1109
+ lane,
1110
+ target,
1111
+ data,
1112
+ envs,
1113
+ port: entry?.port,
1114
+ tokenOverrides: entry?.tokenOverrides
1115
+ })
1074
1116
  );
1075
1117
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1076
1118
  }
@@ -1976,6 +2018,16 @@ function conformanceChecks(t, root) {
1976
2018
  status: gateable ? "ok" : "warn",
1977
2019
  detail: platformPreview ? "vercel per-PR preview + deployment_status verify" : gateable ? void 0 : `no built-in serve for ${t.external ? "an external " : ""}${t.target} tool \u2014 add preview:{ command, \u2026 } so \`greenlight preview ${t.name}\` works`
1978
2020
  });
2021
+ const declared = [...t.tokens ?? [], ...Object.values(t.tokenOverrides ?? {})];
2022
+ if (declared.length) {
2023
+ const tag = t.name.toUpperCase().replace(/-/g, "_");
2024
+ const generic = declared.filter((s) => !s.toUpperCase().includes(tag));
2025
+ out.push({
2026
+ name: `${t.name}: token scoping`,
2027
+ status: generic.length ? "warn" : "ok",
2028
+ detail: generic.length ? `not tool-scoped (should contain ${tag}): ${generic.join(", ")}` : `${declared.length} scoped secret name(s)`
2029
+ });
2030
+ }
1979
2031
  return out;
1980
2032
  }
1981
2033
  function runDoctor(config, root) {
@@ -2280,7 +2332,7 @@ async function initCommand(args) {
2280
2332
  Next:
2281
2333
  1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
2282
2334
  # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
2283
- 2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state-r2.md
2335
+ 2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state.md
2284
2336
  3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
2285
2337
  4. greenlight verify <name> --env prod | greenlight doctor`);
2286
2338
  }
@@ -2296,7 +2348,13 @@ import { resolve as resolve9 } from "path";
2296
2348
  function defaultSpec(lane) {
2297
2349
  switch (lane) {
2298
2350
  case "astro":
2299
- return { mode: "api", checks: [{ path: "/", status: 200 }], noBrokenInternalLinks: true };
2351
+ return {
2352
+ mode: "api",
2353
+ checks: [{ path: "/", status: 200 }],
2354
+ noBrokenInternalLinks: true,
2355
+ settleRetries: 8,
2356
+ settleMs: 5e3
2357
+ };
2300
2358
  case "next":
2301
2359
  return { mode: "api", checks: [{ path: "/", status: 200 }] };
2302
2360
  case "mcp":
@@ -2715,7 +2773,7 @@ var HELP = `greenlight <command>
2715
2773
  doctor manifest + repo consistency checks
2716
2774
  help show this message
2717
2775
 
2718
- Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see greenlight-v1.md \xA716.`;
2776
+ Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see docs/archive/greenlight-v1.md \xA716.`;
2719
2777
  async function main() {
2720
2778
  const [cmd, ...args] = process.argv.slice(2);
2721
2779
  switch (cmd) {
@@ -41,7 +41,7 @@ var ToolSchema = z.object({
41
41
  // (poly-repo) tool sets '.' (the repo root).
42
42
  dir: z.string().optional(),
43
43
  // The tool's code lives in another repo — this entry is a registry pointer only,
44
- // not built/deployed here (greenlight-v1.md §15.5 poly-repo).
44
+ // not built/deployed here (docs/archive/greenlight-v1.md §15.5 poly-repo).
45
45
  external: z.boolean().default(false),
46
46
  // How `greenlight preview` spins the tool up LOCALLY for the pre-deploy gate. Optional — node
47
47
  // lanes (astro/next/mcp→workers) use the built-in build+serve path. Set it for targets with no
@@ -56,7 +56,16 @@ var ToolSchema = z.object({
56
56
  // local port (default: tool.port ?? lane default)
57
57
  path: z.string().optional()
58
58
  // connect path (default: lane default, e.g. `/mcp`)
59
- }).optional()
59
+ }).optional(),
60
+ // The project-scoped secret names this tool needs (e.g. ['TF_VAR_HEISTMIND_GITHUB_ADMIN_TOKEN']).
61
+ // The convention (docs/tokens-reference.md): a project-scoped secret carries the uppercased tool name.
62
+ // `doctor` warns on a name that doesn't — documentation + conformance, no behavior.
63
+ tokens: z.array(z.string()).optional(),
64
+ // Opt-in per-tool provider-token OVERRIDES (multi-account). Maps a provider's default token env
65
+ // var to an alternate secret name, so this tool authenticates that provider with a SECOND account
66
+ // — e.g. { SUPABASE_ACCESS_TOKEN: 'SUPABASE_ACCESS_TOKEN_HEISTMIND' }. Absent ⇒ unchanged (the
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()
60
69
  }).superRefine((tool, ctx) => {
61
70
  const rule = MATRIX[tool.lane];
62
71
  if (!rule.targets.includes(tool.target)) {
@@ -197,8 +206,7 @@ async function checkInternalLinks(base, max = 25) {
197
206
  return { name: "no broken internal links", pass: false, detail: msg(e) };
198
207
  }
199
208
  }
200
- async function verifyApi(baseUrl, spec) {
201
- const base = trimSlash(baseUrl);
209
+ async function runChecks(base, spec) {
202
210
  const checks = [];
203
211
  for (const c of spec.checks ?? []) checks.push(await checkRoute(base, c));
204
212
  if (spec.rssValid) {
@@ -217,6 +225,17 @@ async function verifyApi(baseUrl, spec) {
217
225
  );
218
226
  }
219
227
  if (spec.noBrokenInternalLinks) checks.push(await checkInternalLinks(base));
228
+ return checks;
229
+ }
230
+ async function verifyApi(baseUrl, spec) {
231
+ const base = trimSlash(baseUrl);
232
+ const retries = Math.max(0, spec.settleRetries ?? 0);
233
+ 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++) {
236
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
237
+ checks = await runChecks(base, spec);
238
+ }
220
239
  return report("api", baseUrl, checks);
221
240
  }
222
241
 
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-6USV5AQV.js";
5
+ } from "./chunk-GO2RVNOP.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.19",
3
+ "version": "0.2.21",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,9 +32,9 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@rtrentjones/greenlight-adapters": "0.2.4",
35
- "@rtrentjones/greenlight-loop": "0.2.4",
36
35
  "@rtrentjones/greenlight-shared": "0.2.4",
37
- "@rtrentjones/greenlight-verify": "0.2.4"
36
+ "@rtrentjones/greenlight-verify": "0.2.4",
37
+ "@rtrentjones/greenlight-loop": "0.2.4"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",
@@ -25,4 +25,4 @@ export default { mode: 'mcp', expectTools: ['<tool>'], call: { name: '<tool>' }
25
25
 
26
26
  ## Auth
27
27
 
28
- `auth: none` only for public read-only servers. Mutating/private servers default to `bearer`/`oauth` (greenlight-v1.md §6/§14).
28
+ `auth: none` only for public read-only servers. Mutating/private servers default to `bearer`/`oauth` (docs/archive/greenlight-v1.md §6/§14).
@@ -2,4 +2,4 @@
2
2
 
3
3
  Lane template for **Next.js on Vercel** with Supabase — verify mode `api + playwright`.
4
4
 
5
- > **Phase 0:** placeholder only. Real template content arrives when the `next` lane is exercised (HeistMind migration, **Phase 9** — greenlight-v1.md §16). Materialized into a tool by `greenlight add` / `greenlight adopt`.
5
+ > **Phase 0:** placeholder only. Real template content arrives when the `next` lane is exercised (HeistMind migration, **Phase 9** — docs/archive/greenlight-v1.md §16). Materialized into a tool by `greenlight add` / `greenlight adopt`.