@rtrentjones/greenlight 0.2.8 → 0.2.10

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
@@ -1,24 +1,28 @@
1
1
  # @rtrentjones/greenlight
2
2
 
3
3
  The Greenlight CLI — setup and lifecycle for the [Greenlight](https://github.com/RTrentJones/greenlight)
4
- harness. Greenlight is a **clone-and-own** baseline that turns a domain + API tokens into a live
5
- personal site plus a self-verifying agentic deploy loop, with plug-and-play subdomain tools (web apps
6
- or MCP servers). It is provider-agnostic and free-tier-first, and it **edits declarative
7
- infrastructure-as-code — your CI/CD applies it**. It is not a hosted PaaS.
4
+ harness. Greenlight turns a domain + API tokens into a live personal site plus a self-verifying
5
+ agentic deploy loop, with plug-and-play subdomain tools (web apps or MCP servers). It is
6
+ provider-agnostic and free-tier-first, and it **edits declarative infrastructure-as-code — your CI/CD
7
+ applies it**. It is not a hosted PaaS.
8
8
 
9
- This is the **single published package**: the CLI, with the framework libraries
10
- (`shared`/`verify`/`adapters`/`loop`) bundled in. The Terraform modules are distributed as git tags
11
- (pinned in lockstep with this package's version); the skills ship as a Claude Code plugin.
9
+ **You install this CLI; you don't fork the framework.** `greenlight init` scaffolds a thin **wrapper
10
+ repo you own** (your manifest + content) that depends on this package and updates via
11
+ `pnpm update` no framework code to merge. This is the **single published package**: the CLI, with
12
+ the framework libraries (`shared`/`verify`/`adapters`/`loop`) bundled in. Terraform modules ship as
13
+ git tags (pinned in lockstep with this version); skills ship as a Claude Code plugin.
12
14
 
13
- ## Install
15
+ ## Quick start
14
16
 
15
17
  ```bash
16
- pnpm add @rtrentjones/greenlight # or npm i / yarn add
18
+ npx -y @rtrentjones/greenlight init --domain you.dev # scaffold the wrapper + gather base keys
19
+ pnpm greenlight add notes --lane mcp --target oci # add a tool: emit infra + gather ITS keys
20
+ git push # CI runs `terraform apply`
21
+ pnpm greenlight verify notes --env prod # the shared harness proves it
17
22
  ```
18
23
 
19
- A personal site is a **thin consumer** that depends on this package and owns only its manifest
20
- (`greenlight.config.ts`) + content. Update with `pnpm update @rtrentjones/greenlight` — no framework
21
- code to merge.
24
+ Full walkthrough: [getting-started.md](https://github.com/RTrentJones/greenlight/blob/main/docs/getting-started.md).
25
+ Update the mechanics later with `pnpm update @rtrentjones/greenlight`.
22
26
 
23
27
  Optional peer features lazy-load and degrade to a failing check if absent (never a crash):
24
28
 
@@ -42,6 +42,13 @@ wrapper deploy listener. `greenlight adopt … --target vercel` emits, into the
42
42
  `greenlight verify --url <url> --spec <path>` is the **manifest-free** mode that makes this work
43
43
  without carrying the wrapper's `greenlight.config.ts` into the tool repo.
44
44
 
45
+ **Deployment Protection gotcha:** `deployment_status.target_url` is the `*.vercel.app` *deployment*
46
+ URL, which Vercel **Deployment Protection** gates (→ **401**) even though the public custom domain
47
+ is 200. To verify the real app, create a **Protection Bypass for Automation** secret (Vercel →
48
+ project → Settings → Deployment Protection) and set it as `VERCEL_AUTOMATION_BYPASS_SECRET` on the
49
+ tool repo — the api check sends it as `x-vercel-protection-bypass` and asserts 200. Without it the
50
+ generated spec asserts **401** (the deployment is served + protected), so the gate stays green.
51
+
45
52
  ## MCP
46
53
 
47
54
  `.mcp.json` wires `vercel` (hosted, OAuth, read-only). Run `/mcp` and authenticate in the
package/dist/bin.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  resolveUrl,
7
7
  verifyAll
8
- } from "./chunk-VONSDNH4.js";
8
+ } from "./chunk-JRCATCRY.js";
9
9
  import "./chunk-ADS6BJJ5.js";
10
10
  import "./chunk-WFZTRXBF.js";
11
11
  import "./chunk-KP3Y6WRU.js";
@@ -14,8 +14,8 @@ import "./chunk-6N7MD6FR.js";
14
14
  import "./chunk-QFKE5JKC.js";
15
15
 
16
16
  // src/commands/add.ts
17
- import { cpSync as cpSync2, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
18
- import { join, resolve as resolve6 } from "path";
17
+ import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
18
+ import { join, resolve as resolve5 } from "path";
19
19
 
20
20
  // src/asset-paths.ts
21
21
  import { existsSync } from "fs";
@@ -105,6 +105,30 @@ function addTool(config, t) {
105
105
  }
106
106
  return result.data;
107
107
  }
108
+ function upsertTool(config, t) {
109
+ if (t.name === "blog") throw new Error('"blog" is a reserved name');
110
+ const entry = {
111
+ name: t.name,
112
+ lane: t.lane,
113
+ target: t.target,
114
+ data: t.data ?? "none",
115
+ auth: t.auth ?? "none",
116
+ access: t.access ?? "public",
117
+ envs: t.envs ?? ["beta", "prod"],
118
+ ...t.dir !== void 0 ? { dir: t.dir } : {},
119
+ ...t.adopted ? { adopted: true } : {},
120
+ ...t.external ? { external: true } : {},
121
+ ...t.port !== void 0 ? { port: t.port } : {}
122
+ };
123
+ const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
124
+ const result = ConfigSchema.safeParse({ ...config, tools });
125
+ if (!result.success) {
126
+ throw new Error(
127
+ result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
128
+ );
129
+ }
130
+ return result.data;
131
+ }
108
132
 
109
133
  // src/manifest.ts
110
134
  import { existsSync as existsSync2 } from "fs";
@@ -390,7 +414,7 @@ function tokensForTool(tool) {
390
414
  }
391
415
 
392
416
  // src/version.ts
393
- var MODULE_REF = "v0.2.8";
417
+ var MODULE_REF = "v0.2.10";
394
418
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
395
419
  function moduleSource(module, ref = MODULE_REF) {
396
420
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -630,15 +654,87 @@ function providersForTool(tool) {
630
654
  return out;
631
655
  }
632
656
 
633
- // src/tokens.ts
634
- import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
635
- import { resolve as resolve4 } from "path";
636
- import { createInterface as createInterface2 } from "readline/promises";
657
+ // src/commands/agent.ts
658
+ import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
659
+ import { resolve as resolve3 } from "path";
660
+
661
+ // src/agent-kit.ts
662
+ function recommendedMcp(tool) {
663
+ return mcpForTool(tool);
664
+ }
665
+ function mergeMcpServers(existing, add) {
666
+ const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
667
+ for (const [name, val] of Object.entries(add)) {
668
+ if (out.mcpServers[name]) continue;
669
+ out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
670
+ }
671
+ return out;
672
+ }
673
+
674
+ // src/commands/agent.ts
675
+ var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
676
+
677
+ This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
678
+ branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
679
+
680
+ Agentic kit:
681
+ - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
682
+ - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
683
+ Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
684
+ - Best-practice skills (one-time, user scope):
685
+ \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
686
+ `;
687
+ function materializeAgentKit(dir, tool) {
688
+ const src = skillAssetDir();
689
+ if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
690
+ const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
691
+ mkdirSync(dest, { recursive: true });
692
+ cpSync(src, dest, { recursive: true });
693
+ console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
694
+ for (const pack of packsForTool(tool)) {
695
+ if (!pack.skill) continue;
696
+ const skillSrc = skillAssetDir(pack.skill);
697
+ if (!existsSync3(skillSrc)) continue;
698
+ const skillDest = resolve3(dir, ".claude/skills", pack.skill);
699
+ mkdirSync(skillDest, { recursive: true });
700
+ cpSync(skillSrc, skillDest, { recursive: true });
701
+ console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
702
+ }
703
+ const mcpPath = resolve3(dir, ".mcp.json");
704
+ const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
705
+ const servers = recommendedMcp(tool);
706
+ writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
707
+ `);
708
+ console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
709
+ const claudePath = resolve3(dir, "CLAUDE.md");
710
+ const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
711
+ const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
712
+ if (existing.includes(marker)) {
713
+ console.log("\xB7 CLAUDE.md already has the loop block");
714
+ } else {
715
+ writeFileSync(claudePath, existing ? `${existing.trimEnd()}
716
+
717
+ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
718
+ console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
719
+ }
720
+ }
721
+ async function agentCommand(args) {
722
+ if (args[0] !== "sync") {
723
+ console.log(
724
+ "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
725
+ );
726
+ process.exit(args[0] ? 1 : 0);
727
+ }
728
+ materializeAgentKit(process.cwd());
729
+ console.log(
730
+ "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
731
+ );
732
+ }
637
733
 
638
734
  // src/commands/secrets.ts
639
735
  import { execFileSync } from "child_process";
640
- import { existsSync as existsSync3, readFileSync } from "fs";
641
- import { resolve as resolve3 } from "path";
736
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
737
+ import { resolve as resolve4 } from "path";
642
738
  import { createInterface } from "readline";
643
739
  function parseSecretsEnv(text) {
644
740
  const out = [];
@@ -664,7 +760,7 @@ function parseOciConfig(text) {
664
760
  return out;
665
761
  }
666
762
  function ociPrefill(configPath, keyPath) {
667
- const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
763
+ const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
668
764
  const map = /* @__PURE__ */ new Map();
669
765
  const set = (k, v) => {
670
766
  if (v) map.set(k, v);
@@ -674,8 +770,8 @@ function ociPrefill(configPath, keyPath) {
674
770
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
675
771
  set("TF_VAR_OCI_REGION", cfg.region);
676
772
  const pem = keyPath ?? cfg.key_file;
677
- if (pem && existsSync3(pem)) {
678
- map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
773
+ if (pem && existsSync4(pem)) {
774
+ map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
679
775
  } else if (pem) {
680
776
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
681
777
  }
@@ -709,11 +805,11 @@ function syncSecrets(opts) {
709
805
  "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
710
806
  );
711
807
  }
712
- const path = resolve3(opts.cwd, ".greenlight/secrets.env");
713
- if (!existsSync3(path)) {
808
+ const path = resolve4(opts.cwd, ".greenlight/secrets.env");
809
+ if (!existsSync4(path)) {
714
810
  throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
715
811
  }
716
- const entries = parseSecretsEnv(readFileSync(path, "utf8"));
812
+ const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
717
813
  const target = opts.env ? `env "${opts.env}"` : "repo";
718
814
  for (const { key, value } of entries) {
719
815
  const ghArgs = ["secret", "set", key, "--repo", repo];
@@ -855,7 +951,7 @@ async function secretsCommand(args) {
855
951
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
856
952
  const ociConfig2 = flag(args, "--oci-config");
857
953
  const ociKey = flag(args, "--oci-key");
858
- const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
954
+ const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
859
955
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
860
956
  return;
861
957
  }
@@ -880,161 +976,6 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
880
976
  );
881
977
  }
882
978
 
883
- // src/tokens.ts
884
- var SECRETS_DIR = ".greenlight";
885
- var SECRETS_FILE = "secrets.env";
886
- function presentEnv(cwd) {
887
- const out = {};
888
- const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
889
- if (existsSync4(p)) {
890
- for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
891
- }
892
- for (const [k, v] of Object.entries(process.env)) {
893
- if (v !== void 0 && !(k in out)) out[k] = v;
894
- }
895
- return out;
896
- }
897
- function upsertSecret(cwd, key, value) {
898
- const dir = resolve4(cwd, SECRETS_DIR);
899
- mkdirSync(dir, { recursive: true });
900
- const p = resolve4(dir, SECRETS_FILE);
901
- const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
902
- const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
903
- if (idx >= 0) lines[idx] = `${key}=${value}`;
904
- else {
905
- while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
906
- lines.push(`${key}=${value}`);
907
- }
908
- writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
909
- `, { mode: 384 });
910
- }
911
- async function ensureTokensForTool(cwd, tool, opts = {}) {
912
- const doVerify = opts.verify !== false;
913
- const interactive = Boolean(process.stdin.isTTY);
914
- const env = presentEnv(cwd);
915
- const results = [];
916
- const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
917
- try {
918
- for (const spec of tokensForTool(tool)) {
919
- let value = env[spec.envVar];
920
- if (value) {
921
- results.push({ envVar: spec.envVar, outcome: "present" });
922
- } else if (rl) {
923
- console.log(`
924
- ${spec.envVar} \u2014 ${spec.label}`);
925
- if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
926
- const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
927
- if (!entered) {
928
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
929
- continue;
930
- }
931
- upsertSecret(cwd, spec.envVar, entered);
932
- env[spec.envVar] = entered;
933
- value = entered;
934
- results.push({ envVar: spec.envVar, outcome: "entered" });
935
- } else {
936
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
937
- continue;
938
- }
939
- if (value && doVerify && spec.verify) {
940
- let check;
941
- try {
942
- check = await spec.verify(value, env);
943
- } catch (e) {
944
- check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
945
- }
946
- const last = results[results.length - 1];
947
- if (last) last.verify = check;
948
- if (!check.ok && !spec.optional) {
949
- throw new Error(
950
- `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
951
- );
952
- }
953
- }
954
- }
955
- } finally {
956
- rl?.close();
957
- }
958
- return results;
959
- }
960
-
961
- // src/commands/agent.ts
962
- import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
963
- import { resolve as resolve5 } from "path";
964
-
965
- // src/agent-kit.ts
966
- function recommendedMcp(tool) {
967
- return mcpForTool(tool);
968
- }
969
- function mergeMcpServers(existing, add) {
970
- const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
971
- for (const [name, val] of Object.entries(add)) {
972
- if (out.mcpServers[name]) continue;
973
- out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
974
- }
975
- return out;
976
- }
977
-
978
- // src/commands/agent.ts
979
- var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
980
-
981
- This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
982
- branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
983
-
984
- Agentic kit:
985
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
986
- - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
987
- Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
988
- - Best-practice skills (one-time, user scope):
989
- \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
990
- `;
991
- function materializeAgentKit(dir, tool) {
992
- const src = skillAssetDir();
993
- if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
994
- const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
995
- mkdirSync2(dest, { recursive: true });
996
- cpSync(src, dest, { recursive: true });
997
- console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
998
- for (const pack of packsForTool(tool)) {
999
- if (!pack.skill) continue;
1000
- const skillSrc = skillAssetDir(pack.skill);
1001
- if (!existsSync5(skillSrc)) continue;
1002
- const skillDest = resolve5(dir, ".claude/skills", pack.skill);
1003
- mkdirSync2(skillDest, { recursive: true });
1004
- cpSync(skillSrc, skillDest, { recursive: true });
1005
- console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
1006
- }
1007
- const mcpPath = resolve5(dir, ".mcp.json");
1008
- const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
1009
- const servers = recommendedMcp(tool);
1010
- writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
1011
- `);
1012
- console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
1013
- const claudePath = resolve5(dir, "CLAUDE.md");
1014
- const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
1015
- const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
1016
- if (existing.includes(marker)) {
1017
- console.log("\xB7 CLAUDE.md already has the loop block");
1018
- } else {
1019
- writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
1020
-
1021
- ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
1022
- console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
1023
- }
1024
- }
1025
- async function agentCommand(args) {
1026
- if (args[0] !== "sync") {
1027
- console.log(
1028
- "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
1029
- );
1030
- process.exit(args[0] ? 1 : 0);
1031
- }
1032
- materializeAgentKit(process.cwd());
1033
- console.log(
1034
- "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1035
- );
1036
- }
1037
-
1038
979
  // src/commands/add.ts
1039
980
  function flag2(args, name) {
1040
981
  const i = args.indexOf(name);
@@ -1072,71 +1013,69 @@ async function addCommand(args) {
1072
1013
  const data = entry?.data ?? "none";
1073
1014
  const envs = entry?.envs ?? ["beta", "prod"];
1074
1015
  const toolInfo = { target, data };
1075
- const dest = resolve6(process.cwd(), "tools", name);
1076
- if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
1016
+ const dest = resolve5(process.cwd(), "tools", name);
1017
+ if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
1077
1018
  const src = templateDir(lane, target);
1078
- if (existsSync6(src)) {
1019
+ if (existsSync5(src)) {
1079
1020
  cpSync2(src, dest, { recursive: true });
1080
1021
  const pkgPath = join(dest, "package.json");
1081
- if (existsSync6(pkgPath)) {
1082
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1022
+ if (existsSync5(pkgPath)) {
1023
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1083
1024
  pkg.name = name;
1084
- writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1025
+ writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1085
1026
  `);
1086
1027
  }
1087
1028
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1088
1029
  } else {
1089
1030
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1090
1031
  }
1091
- writeFileSync3(path, serializeConfig(next));
1032
+ writeFileSync2(path, serializeConfig(next));
1092
1033
  console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
1093
1034
  const cwd = process.cwd();
1094
1035
  const providers = providersForTool(toolInfo);
1095
- const infraDir = resolve6(cwd, "infra");
1036
+ const infraDir = resolve5(cwd, "infra");
1096
1037
  const mainTf = join(infraDir, "main.tf");
1097
- if (!existsSync6(mainTf)) {
1098
- mkdirSync3(infraDir, { recursive: true });
1099
- writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1038
+ if (!existsSync5(mainTf)) {
1039
+ mkdirSync2(infraDir, { recursive: true });
1040
+ writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1100
1041
  console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
1101
1042
  } else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
1102
1043
  console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
1103
1044
  }
1104
1045
  const toolTf = join(infraDir, `${name}.tf`);
1105
- if (existsSync6(toolTf)) {
1046
+ if (existsSync5(toolTf)) {
1106
1047
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1107
1048
  } else {
1108
- writeFileSync3(
1049
+ writeFileSync2(
1109
1050
  toolTf,
1110
1051
  emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
1111
1052
  );
1112
1053
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1113
1054
  }
1114
- if (!args.includes("--no-tokens")) {
1055
+ materializeAgentKit(cwd, toolInfo);
1056
+ const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
1057
+ const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
1058
+ if (gather) {
1059
+ console.log(`
1060
+ Gathering ${name}'s provider keys${repo ? ` \u2192 ${repo}` : ""}:`);
1115
1061
  try {
1116
- const outcomes = await ensureTokensForTool(cwd, toolInfo, {
1117
- verify: !args.includes("--no-verify")
1118
- });
1119
- const missing = outcomes.filter((o) => o.outcome === "missing").map((o) => o.envVar);
1120
- if (missing.length) {
1121
- console.log(
1122
- `! missing token(s): ${missing.join(", ")} \u2014 set in .greenlight/secrets.env, then \`greenlight secrets sync\``
1123
- );
1124
- }
1062
+ await gatherSecrets(name, repo, flag2(args, "--env"));
1125
1063
  } catch (e) {
1126
- console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
1064
+ console.log(`\u2716 secrets gather: ${e instanceof Error ? e.message : String(e)}`);
1065
+ console.log(` retry: greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : ""}`);
1127
1066
  }
1128
1067
  }
1129
- materializeAgentKit(cwd, toolInfo);
1130
1068
  console.log(`
1131
- Next:
1069
+ Next:${gather ? "" : `
1070
+ greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : " --repo <owner/repo>"} # this tool's keys \u2192 GitHub`}
1132
1071
  review infra/${name}.tf, then commit + push \u2192 CI (infra.yml) runs \`terraform apply\`
1133
1072
  greenlight preview ${name} # local build + serve + verify`);
1134
1073
  }
1135
1074
 
1136
1075
  // src/commands/adopt.ts
1137
1076
  import { execFileSync as execFileSync2 } from "child_process";
1138
- import { cpSync as cpSync3, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync4 } from "fs";
1139
- import { join as join2, resolve as resolve7 } from "path";
1077
+ import { cpSync as cpSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
1078
+ import { join as join2, resolve as resolve6 } from "path";
1140
1079
  var REF = MODULE_REF;
1141
1080
  function flag3(args, name) {
1142
1081
  const i = args.indexOf(name);
@@ -1152,7 +1091,7 @@ function mergePackageJson(existing, repoName, vendor) {
1152
1091
  }
1153
1092
  function vendorDeps(vendorDir) {
1154
1093
  const out = {};
1155
- if (!existsSync7(vendorDir)) return out;
1094
+ if (!existsSync6(vendorDir)) return out;
1156
1095
  for (const f of readdirSync(vendorDir)) {
1157
1096
  if (!f.endsWith(".tgz")) continue;
1158
1097
  const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
@@ -1443,14 +1382,14 @@ jobs:
1443
1382
  - uses: actions/setup-node@v4
1444
1383
  with:
1445
1384
  node-version: '24'
1446
- - name: Install deps (for test-mode)
1447
- run: |
1448
- corepack enable || true
1449
- if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile;
1450
- elif [ -f yarn.lock ]; then yarn install --frozen-lockfile;
1451
- else npm ci; fi
1385
+ # agent-web needs browsers: add \`- run: npx -y playwright install --with-deps chromium\` when
1386
+ # you set ANTHROPIC_API_KEY. test-mode needs the tool's deps: add a tolerant install step
1387
+ # (\`pnpm install --no-frozen-lockfile\`) \u2014 but unit tests usually belong in the tool's PR CI.
1452
1388
  - name: Verify the deployment
1453
1389
  env:
1390
+ # Bypass Vercel Deployment Protection on the deployment URL (Vercel \u2192 project \u2192 Deployment
1391
+ # Protection \u2192 Protection Bypass for Automation). Without it the gate asserts 401 (served).
1392
+ VERCEL_AUTOMATION_BYPASS_SECRET: \${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
1454
1393
  ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
1455
1394
  run: npx -y @rtrentjones/greenlight@latest verify --url "\${{ github.event.deployment_status.target_url }}" --spec verify/${name}.config.ts
1456
1395
  `;
@@ -1458,10 +1397,20 @@ jobs:
1458
1397
  function nextVerifyConfig(name) {
1459
1398
  return `// Greenlight verify spec for ${name} (next/vercel) \u2014 run by .github/workflows/greenlight-verify.yml
1460
1399
  // after Vercel deploys (deployment_status). An array combines modes (allPass):
1461
- // - api: the deployed URL serves (200).
1462
- // - test: this tool's own suite \u2014 set the real command for your package manager.
1400
+ // - api: deployment_status' target_url is the *.vercel.app deployment URL, which Vercel Deployment
1401
+ // Protection gates (401). With VERCEL_AUTOMATION_BYPASS_SECRET set we send the bypass header and
1402
+ // assert 200 (the real app); without it we assert 401 (the deployment is served + protected).
1463
1403
  // - agent-web: an LLM drives the live UI; runs ONLY when ANTHROPIC_API_KEY is set (else omitted,
1464
1404
  // so the gate stays green). Replace the scenario with real user tasks + assertions.
1405
+ // Unit tests belong in this repo's PR CI; to also gate the deploy on them, add
1406
+ // { mode: 'test', command: 'pnpm test' } + a tolerant deps-install step in greenlight-verify.yml.
1407
+ const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
1408
+ const api = bypass
1409
+ ? {
1410
+ mode: 'api',
1411
+ checks: [{ path: '/', status: 200, requestHeaders: { 'x-vercel-protection-bypass': bypass } }],
1412
+ }
1413
+ : { mode: 'api', checks: [{ path: '/', status: 401 }] };
1465
1414
  const agentWeb = process.env.ANTHROPIC_API_KEY
1466
1415
  ? [
1467
1416
  {
@@ -1477,20 +1426,16 @@ const agentWeb = process.env.ANTHROPIC_API_KEY
1477
1426
  ]
1478
1427
  : [];
1479
1428
 
1480
- export default [
1481
- { mode: 'api', checks: [{ path: '/', status: 200 }] },
1482
- { mode: 'test', command: 'npm test' },
1483
- ...agentWeb,
1484
- ];
1429
+ export default [api, ...agentWeb];
1485
1430
  `;
1486
1431
  }
1487
1432
  function writeIfAbsent(path, contents, label) {
1488
- if (existsSync7(path)) {
1433
+ if (existsSync6(path)) {
1489
1434
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
1490
1435
  return;
1491
1436
  }
1492
- mkdirSync4(resolve7(path, ".."), { recursive: true });
1493
- writeFileSync4(path, contents);
1437
+ mkdirSync3(resolve6(path, ".."), { recursive: true });
1438
+ writeFileSync3(path, contents);
1494
1439
  console.log(`\u2714 ${label}`);
1495
1440
  }
1496
1441
  async function adoptCommand(args) {
@@ -1514,8 +1459,8 @@ async function adoptCommand(args) {
1514
1459
  "run adopt from your site repo (needs a real greenlight.config.ts; run `greenlight init` first)"
1515
1460
  );
1516
1461
  }
1517
- if (reg.tools.some((t) => t.name === name) || name === "blog") {
1518
- throw new Error(`"${name}" already in the registry`);
1462
+ if (name === "blog") {
1463
+ throw new Error('"blog" is the apex site, not an adopted tool');
1519
1464
  }
1520
1465
  const domain = flag3(args, "--domain") ?? reg.domain;
1521
1466
  const ctx = { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath };
@@ -1526,10 +1471,10 @@ async function adoptWrapper(ctx) {
1526
1471
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1527
1472
  const cwd = process.cwd();
1528
1473
  const toolRel = `tools/${name}`;
1529
- const dest = resolve7(cwd, toolRel);
1474
+ const dest = resolve6(cwd, toolRel);
1530
1475
  console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
1531
1476
  `);
1532
- if (!existsSync7(dest)) {
1477
+ if (!existsSync6(dest)) {
1533
1478
  try {
1534
1479
  execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
1535
1480
  console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
@@ -1542,7 +1487,8 @@ async function adoptWrapper(ctx) {
1542
1487
  } else {
1543
1488
  console.log(`\xB7 ${toolRel} exists \u2014 skipping submodule add`);
1544
1489
  }
1545
- const nextReg = addTool(reg, {
1490
+ const existed = reg.tools.some((x) => x.name === name);
1491
+ const nextReg = upsertTool(reg, {
1546
1492
  name,
1547
1493
  lane,
1548
1494
  target,
@@ -1553,8 +1499,10 @@ async function adoptWrapper(ctx) {
1553
1499
  external: true,
1554
1500
  adopted: true
1555
1501
  });
1556
- writeFileSync4(regPath, serializeConfig(nextReg));
1557
- console.log(`\u2714 registered "${name}" (external, dir ${toolRel}) in the wrapper manifest`);
1502
+ writeFileSync3(regPath, serializeConfig(nextReg));
1503
+ console.log(
1504
+ `\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
1505
+ );
1558
1506
  const slug = parseRepo(repoArg) ?? parseRepo(safeGit(dest, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
1559
1507
  writeIfAbsent(
1560
1508
  join2(cwd, `infra/${name}.tf`),
@@ -1569,7 +1517,7 @@ async function adoptWrapper(ctx) {
1569
1517
  );
1570
1518
  }
1571
1519
  const providers = providersForTool({ target, data });
1572
- if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1520
+ if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1573
1521
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
1574
1522
  }
1575
1523
  materializeAgentKit(dest, { target, data });
@@ -1615,9 +1563,9 @@ Next:
1615
1563
  }
1616
1564
  async function adoptStandalone(ctx) {
1617
1565
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1618
- const repo = resolve7(process.cwd(), repoArg);
1619
- if (!existsSync7(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1620
- const regVendor = resolve7(process.cwd(), "vendor");
1566
+ const repo = resolve6(process.cwd(), repoArg);
1567
+ if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1568
+ const regVendor = resolve6(process.cwd(), "vendor");
1621
1569
  const vendor = vendorDeps(regVendor);
1622
1570
  if (Object.keys(vendor).length === 0) {
1623
1571
  throw new Error(
@@ -1635,15 +1583,15 @@ async function adoptStandalone(ctx) {
1635
1583
  );
1636
1584
  const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
1637
1585
  const pkgPath = join2(repo, "package.json");
1638
- const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
1639
- writeFileSync4(
1586
+ const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
1587
+ writeFileSync3(
1640
1588
  pkgPath,
1641
1589
  `${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
1642
1590
  `
1643
1591
  );
1644
1592
  console.log("\u2714 package.json (merged framework deps + overrides)");
1645
1593
  const repoVendor = join2(repo, "vendor");
1646
- mkdirSync4(repoVendor, { recursive: true });
1594
+ mkdirSync3(repoVendor, { recursive: true });
1647
1595
  for (const f of readdirSync(regVendor)) {
1648
1596
  if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
1649
1597
  }
@@ -1677,7 +1625,7 @@ async function adoptStandalone(ctx) {
1677
1625
  external: true,
1678
1626
  adopted: true
1679
1627
  });
1680
- writeFileSync4(regPath, serializeConfig(nextReg));
1628
+ writeFileSync3(regPath, serializeConfig(nextReg));
1681
1629
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
1682
1630
  console.log(`
1683
1631
  Next (in the adopted repo):
@@ -1690,20 +1638,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
1690
1638
  }
1691
1639
  function addGreenlightScript(dir) {
1692
1640
  const pkgPath = join2(dir, "package.json");
1693
- if (!existsSync7(pkgPath)) {
1641
+ if (!existsSync6(pkgPath)) {
1694
1642
  console.log(
1695
1643
  "\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
1696
1644
  );
1697
1645
  return;
1698
1646
  }
1699
- const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
1647
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1700
1648
  pkg.scripts = { ...pkg.scripts ?? {} };
1701
1649
  if (pkg.scripts.greenlight) {
1702
1650
  console.log("\xB7 package.json already has a greenlight script");
1703
1651
  return;
1704
1652
  }
1705
1653
  pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
1706
- writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
1654
+ writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1707
1655
  `);
1708
1656
  console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
1709
1657
  }
@@ -1846,10 +1794,10 @@ async function deployCommand(args) {
1846
1794
  }
1847
1795
 
1848
1796
  // src/commands/doctor.ts
1849
- import { existsSync as existsSync8 } from "fs";
1797
+ import { existsSync as existsSync7 } from "fs";
1850
1798
  import { join as join4 } from "path";
1851
1799
  function dirCheck(label, dir) {
1852
- return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1800
+ return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1853
1801
  }
1854
1802
  function runDoctor(config, root) {
1855
1803
  const checks = [];
@@ -1871,8 +1819,8 @@ function runDoctor(config, root) {
1871
1819
  const vc = join4(dir, "verify.config.ts");
1872
1820
  checks.push({
1873
1821
  name: `${t.name}: verify.config.ts`,
1874
- status: existsSync8(vc) ? "ok" : "warn",
1875
- detail: existsSync8(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1822
+ status: existsSync7(vc) ? "ok" : "warn",
1823
+ detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1876
1824
  });
1877
1825
  }
1878
1826
  }
@@ -1918,10 +1866,180 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
1918
1866
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1919
1867
  import { resolve as resolve8 } from "path";
1920
1868
  import { createInterface as createInterface3 } from "readline/promises";
1869
+
1870
+ // src/tokens.ts
1871
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
1872
+ import { resolve as resolve7 } from "path";
1873
+ import { createInterface as createInterface2 } from "readline/promises";
1874
+ var SECRETS_DIR = ".greenlight";
1875
+ var SECRETS_FILE = "secrets.env";
1876
+ function presentEnv(cwd) {
1877
+ const out = {};
1878
+ const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
1879
+ if (existsSync8(p)) {
1880
+ for (const { key, value } of parseSecretsEnv(readFileSync5(p, "utf8"))) out[key] = value;
1881
+ }
1882
+ for (const [k, v] of Object.entries(process.env)) {
1883
+ if (v !== void 0 && !(k in out)) out[k] = v;
1884
+ }
1885
+ return out;
1886
+ }
1887
+ function upsertSecret(cwd, key, value) {
1888
+ const dir = resolve7(cwd, SECRETS_DIR);
1889
+ mkdirSync4(dir, { recursive: true });
1890
+ const p = resolve7(dir, SECRETS_FILE);
1891
+ const lines = existsSync8(p) ? readFileSync5(p, "utf8").split("\n") : [];
1892
+ const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
1893
+ if (idx >= 0) lines[idx] = `${key}=${value}`;
1894
+ else {
1895
+ while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
1896
+ lines.push(`${key}=${value}`);
1897
+ }
1898
+ writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
1899
+ `, { mode: 384 });
1900
+ }
1901
+ async function ensureTokensForTool(cwd, tool, opts = {}) {
1902
+ const doVerify = opts.verify !== false;
1903
+ const interactive = Boolean(process.stdin.isTTY);
1904
+ const env = presentEnv(cwd);
1905
+ const results = [];
1906
+ const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
1907
+ try {
1908
+ for (const spec of tokensForTool(tool)) {
1909
+ let value = env[spec.envVar];
1910
+ if (value) {
1911
+ results.push({ envVar: spec.envVar, outcome: "present" });
1912
+ } else if (rl) {
1913
+ console.log(`
1914
+ ${spec.envVar} \u2014 ${spec.label}`);
1915
+ if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
1916
+ const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
1917
+ if (!entered) {
1918
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1919
+ continue;
1920
+ }
1921
+ upsertSecret(cwd, spec.envVar, entered);
1922
+ env[spec.envVar] = entered;
1923
+ value = entered;
1924
+ results.push({ envVar: spec.envVar, outcome: "entered" });
1925
+ } else {
1926
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1927
+ continue;
1928
+ }
1929
+ if (value && doVerify && spec.verify) {
1930
+ let check;
1931
+ try {
1932
+ check = await spec.verify(value, env);
1933
+ } catch (e) {
1934
+ check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
1935
+ }
1936
+ const last = results[results.length - 1];
1937
+ if (last) last.verify = check;
1938
+ if (!check.ok && !spec.optional) {
1939
+ throw new Error(
1940
+ `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
1941
+ );
1942
+ }
1943
+ }
1944
+ }
1945
+ } finally {
1946
+ rl?.close();
1947
+ }
1948
+ return results;
1949
+ }
1950
+
1951
+ // src/commands/init.ts
1921
1952
  function flag5(args, name) {
1922
1953
  const i = args.indexOf(name);
1923
1954
  return i >= 0 ? args[i + 1] : void 0;
1924
1955
  }
1956
+ var NPM_DEP = `^${MODULE_REF.replace(/^v/, "")}`;
1957
+ function wrapperPackageJson(name) {
1958
+ return `${JSON.stringify(
1959
+ {
1960
+ name,
1961
+ private: true,
1962
+ type: "module",
1963
+ scripts: { greenlight: "greenlight" },
1964
+ dependencies: { "@rtrentjones/greenlight": NPM_DEP }
1965
+ },
1966
+ null,
1967
+ 2
1968
+ )}
1969
+ `;
1970
+ }
1971
+ var WRAPPER_GITIGNORE = `# Greenlight wrapper
1972
+ node_modules/
1973
+ .greenlight/ # gathered tokens \u2014 never committed
1974
+ .terraform/
1975
+ *.tfplan
1976
+ tf.plan
1977
+ dist/
1978
+ `;
1979
+ var WRAPPER_MISE = `[tools]
1980
+ node = "24"
1981
+ pnpm = "10.12.1"
1982
+ `;
1983
+ function wrapperInfraYml() {
1984
+ return `name: infra
1985
+
1986
+ # Apply the wrapper's Terraform on push to main (paths: infra/**). State + locking are in HCP
1987
+ # Terraform (set the cloud{} block in infra/main.tf); the run happens here with provider creds
1988
+ # from GitHub Actions secrets (populate them per tool via \`greenlight secrets gather\`).
1989
+ on:
1990
+ push:
1991
+ branches: [main]
1992
+ paths: ['infra/**']
1993
+ workflow_dispatch:
1994
+
1995
+ permissions:
1996
+ contents: read
1997
+
1998
+ concurrency:
1999
+ group: infra
2000
+ cancel-in-progress: false # never interrupt an in-flight apply
2001
+
2002
+ jobs:
2003
+ apply:
2004
+ runs-on: ubuntu-latest
2005
+ env:
2006
+ TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
2007
+ GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
2008
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
2009
+ TF_VAR_cloudflare_zone_id: \${{ secrets.TF_VAR_CLOUDFLARE_ZONE_ID }}
2010
+ TF_VAR_cloudflare_account_id: \${{ secrets.TF_VAR_CLOUDFLARE_ACCOUNT_ID }}
2011
+ # vercel (target: vercel tools)
2012
+ VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
2013
+ # supabase (data: supabase tools)
2014
+ SUPABASE_ACCESS_TOKEN: \${{ secrets.SUPABASE_ACCESS_TOKEN }}
2015
+ TF_VAR_supabase_database_password: \${{ secrets.TF_VAR_SUPABASE_DATABASE_PASSWORD }}
2016
+ # oci (target: oci tools) \u2014 VCN/subnet/AD are IaC; only auth (+ optional compartment) here
2017
+ TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
2018
+ TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
2019
+ TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
2020
+ TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
2021
+ TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
2022
+ TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
2023
+ steps:
2024
+ - uses: actions/checkout@v4
2025
+ - uses: hashicorp/setup-terraform@v3
2026
+ with:
2027
+ terraform_version: '~1.10'
2028
+ terraform_wrapper: false
2029
+ - run: terraform -chdir=infra init -input=false
2030
+ - run: terraform -chdir=infra plan -input=false -out=tf.plan
2031
+ - run: terraform -chdir=infra apply -input=false tf.plan
2032
+ `;
2033
+ }
2034
+ function scaffoldIfAbsent(path, contents, label) {
2035
+ if (existsSync9(path)) {
2036
+ console.log(`\xB7 ${label} exists \u2014 left as-is`);
2037
+ return;
2038
+ }
2039
+ mkdirSync5(resolve8(path, ".."), { recursive: true });
2040
+ writeFileSync5(path, contents);
2041
+ console.log(`\u2714 wrote ${label}`);
2042
+ }
1925
2043
  var TOKEN_FLAGS = {
1926
2044
  "--cf-token": "CLOUDFLARE_API_TOKEN",
1927
2045
  "--github-token": "GITHUB_TOKEN",
@@ -1946,6 +2064,16 @@ async function initCommand(args) {
1946
2064
  }
1947
2065
  writeFileSync5(configPath, scaffoldConfig(domain));
1948
2066
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2067
+ const repoName = domain.replace(/\./g, "-");
2068
+ scaffoldIfAbsent(
2069
+ resolve8(cwd, ".github/workflows/infra.yml"),
2070
+ wrapperInfraYml(),
2071
+ ".github/workflows/infra.yml (HCP-backed terraform apply on push)"
2072
+ );
2073
+ scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2074
+ scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2075
+ scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2076
+ scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
1949
2077
  const secrets = [];
1950
2078
  for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
1951
2079
  const v = flag5(args, f);
@@ -1979,10 +2107,11 @@ async function initCommand(args) {
1979
2107
  }
1980
2108
  console.log(`
1981
2109
  Next:
1982
- greenlight add <name> --lane mcp --target oci # scaffold a tool${pushed ? "" : "\n greenlight secrets sync # push tokens to GitHub Actions"}
1983
- greenlight doctor # check consistency
1984
- terraform -chdir=infra apply # branches/protection/DNS (module "repo"/"tool")
1985
- greenlight deploy blog --env prod # first live deploy`);
2110
+ 1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
2111
+ # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
2112
+ 2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state-r2.md
2113
+ 3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
2114
+ 4. greenlight verify <name> --env prod | greenlight doctor`);
1986
2115
  }
1987
2116
 
1988
2117
  // src/commands/preview.ts
@@ -116,7 +116,7 @@ var trimSlash = (s) => s.replace(/\/+$/, "");
116
116
  async function checkRoute(base, c) {
117
117
  const name = `GET ${c.path}`;
118
118
  try {
119
- const res = await fetch(base + c.path, { redirect: "manual" });
119
+ const res = await fetch(base + c.path, { redirect: "manual", headers: c.requestHeaders });
120
120
  const reasons = [];
121
121
  if (c.status !== void 0 && res.status !== c.status) {
122
122
  reasons.push(`status ${res.status} != ${c.status}`);
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-VONSDNH4.js";
5
+ } from "./chunk-JRCATCRY.js";
6
6
  import "./chunk-ADS6BJJ5.js";
7
7
  import "./chunk-WFZTRXBF.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.8",
3
+ "version": "0.2.10",
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.4",
34
+ "@rtrentjones/greenlight-loop": "0.2.4",
35
35
  "@rtrentjones/greenlight-shared": "0.2.4",
36
36
  "@rtrentjones/greenlight-verify": "0.2.4",
37
- "@rtrentjones/greenlight-loop": "0.2.4"
37
+ "@rtrentjones/greenlight-adapters": "0.2.4"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",