@rtrentjones/greenlight 0.2.9 → 0.2.11

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.
Files changed (3) hide show
  1. package/README.md +16 -12
  2. package/dist/bin.js +464 -251
  3. package/package.json +2 -2
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
 
package/dist/bin.js CHANGED
@@ -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";
@@ -414,7 +414,7 @@ function tokensForTool(tool) {
414
414
  }
415
415
 
416
416
  // src/version.ts
417
- var MODULE_REF = "v0.2.9";
417
+ var MODULE_REF = "v0.2.11";
418
418
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
419
419
  function moduleSource(module, ref = MODULE_REF) {
420
420
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -654,15 +654,87 @@ function providersForTool(tool) {
654
654
  return out;
655
655
  }
656
656
 
657
- // src/tokens.ts
658
- import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
659
- import { resolve as resolve4 } from "path";
660
- 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
+ }
661
733
 
662
734
  // src/commands/secrets.ts
663
735
  import { execFileSync } from "child_process";
664
- import { existsSync as existsSync3, readFileSync } from "fs";
665
- import { resolve as resolve3 } from "path";
736
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
737
+ import { resolve as resolve4 } from "path";
666
738
  import { createInterface } from "readline";
667
739
  function parseSecretsEnv(text) {
668
740
  const out = [];
@@ -688,7 +760,7 @@ function parseOciConfig(text) {
688
760
  return out;
689
761
  }
690
762
  function ociPrefill(configPath, keyPath) {
691
- const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
763
+ const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
692
764
  const map = /* @__PURE__ */ new Map();
693
765
  const set = (k, v) => {
694
766
  if (v) map.set(k, v);
@@ -698,8 +770,8 @@ function ociPrefill(configPath, keyPath) {
698
770
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
699
771
  set("TF_VAR_OCI_REGION", cfg.region);
700
772
  const pem = keyPath ?? cfg.key_file;
701
- if (pem && existsSync3(pem)) {
702
- 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"));
703
775
  } else if (pem) {
704
776
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
705
777
  }
@@ -733,11 +805,11 @@ function syncSecrets(opts) {
733
805
  "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
734
806
  );
735
807
  }
736
- const path = resolve3(opts.cwd, ".greenlight/secrets.env");
737
- if (!existsSync3(path)) {
808
+ const path = resolve4(opts.cwd, ".greenlight/secrets.env");
809
+ if (!existsSync4(path)) {
738
810
  throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
739
811
  }
740
- const entries = parseSecretsEnv(readFileSync(path, "utf8"));
812
+ const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
741
813
  const target = opts.env ? `env "${opts.env}"` : "repo";
742
814
  for (const { key, value } of entries) {
743
815
  const ghArgs = ["secret", "set", key, "--repo", repo];
@@ -879,7 +951,7 @@ async function secretsCommand(args) {
879
951
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
880
952
  const ociConfig2 = flag(args, "--oci-config");
881
953
  const ociKey = flag(args, "--oci-key");
882
- 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;
883
955
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
884
956
  return;
885
957
  }
@@ -904,161 +976,6 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
904
976
  );
905
977
  }
906
978
 
907
- // src/tokens.ts
908
- var SECRETS_DIR = ".greenlight";
909
- var SECRETS_FILE = "secrets.env";
910
- function presentEnv(cwd) {
911
- const out = {};
912
- const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
913
- if (existsSync4(p)) {
914
- for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
915
- }
916
- for (const [k, v] of Object.entries(process.env)) {
917
- if (v !== void 0 && !(k in out)) out[k] = v;
918
- }
919
- return out;
920
- }
921
- function upsertSecret(cwd, key, value) {
922
- const dir = resolve4(cwd, SECRETS_DIR);
923
- mkdirSync(dir, { recursive: true });
924
- const p = resolve4(dir, SECRETS_FILE);
925
- const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
926
- const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
927
- if (idx >= 0) lines[idx] = `${key}=${value}`;
928
- else {
929
- while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
930
- lines.push(`${key}=${value}`);
931
- }
932
- writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
933
- `, { mode: 384 });
934
- }
935
- async function ensureTokensForTool(cwd, tool, opts = {}) {
936
- const doVerify = opts.verify !== false;
937
- const interactive = Boolean(process.stdin.isTTY);
938
- const env = presentEnv(cwd);
939
- const results = [];
940
- const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
941
- try {
942
- for (const spec of tokensForTool(tool)) {
943
- let value = env[spec.envVar];
944
- if (value) {
945
- results.push({ envVar: spec.envVar, outcome: "present" });
946
- } else if (rl) {
947
- console.log(`
948
- ${spec.envVar} \u2014 ${spec.label}`);
949
- if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
950
- const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
951
- if (!entered) {
952
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
953
- continue;
954
- }
955
- upsertSecret(cwd, spec.envVar, entered);
956
- env[spec.envVar] = entered;
957
- value = entered;
958
- results.push({ envVar: spec.envVar, outcome: "entered" });
959
- } else {
960
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
961
- continue;
962
- }
963
- if (value && doVerify && spec.verify) {
964
- let check;
965
- try {
966
- check = await spec.verify(value, env);
967
- } catch (e) {
968
- check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
969
- }
970
- const last = results[results.length - 1];
971
- if (last) last.verify = check;
972
- if (!check.ok && !spec.optional) {
973
- throw new Error(
974
- `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
975
- );
976
- }
977
- }
978
- }
979
- } finally {
980
- rl?.close();
981
- }
982
- return results;
983
- }
984
-
985
- // src/commands/agent.ts
986
- import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
987
- import { resolve as resolve5 } from "path";
988
-
989
- // src/agent-kit.ts
990
- function recommendedMcp(tool) {
991
- return mcpForTool(tool);
992
- }
993
- function mergeMcpServers(existing, add) {
994
- const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
995
- for (const [name, val] of Object.entries(add)) {
996
- if (out.mcpServers[name]) continue;
997
- out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
998
- }
999
- return out;
1000
- }
1001
-
1002
- // src/commands/agent.ts
1003
- var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
1004
-
1005
- This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
1006
- branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
1007
-
1008
- Agentic kit:
1009
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
1010
- - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
1011
- Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
1012
- - Best-practice skills (one-time, user scope):
1013
- \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
1014
- `;
1015
- function materializeAgentKit(dir, tool) {
1016
- const src = skillAssetDir();
1017
- if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
1018
- const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
1019
- mkdirSync2(dest, { recursive: true });
1020
- cpSync(src, dest, { recursive: true });
1021
- console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
1022
- for (const pack of packsForTool(tool)) {
1023
- if (!pack.skill) continue;
1024
- const skillSrc = skillAssetDir(pack.skill);
1025
- if (!existsSync5(skillSrc)) continue;
1026
- const skillDest = resolve5(dir, ".claude/skills", pack.skill);
1027
- mkdirSync2(skillDest, { recursive: true });
1028
- cpSync(skillSrc, skillDest, { recursive: true });
1029
- console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
1030
- }
1031
- const mcpPath = resolve5(dir, ".mcp.json");
1032
- const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
1033
- const servers = recommendedMcp(tool);
1034
- writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
1035
- `);
1036
- console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
1037
- const claudePath = resolve5(dir, "CLAUDE.md");
1038
- const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
1039
- const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
1040
- if (existing.includes(marker)) {
1041
- console.log("\xB7 CLAUDE.md already has the loop block");
1042
- } else {
1043
- writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
1044
-
1045
- ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
1046
- console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
1047
- }
1048
- }
1049
- async function agentCommand(args) {
1050
- if (args[0] !== "sync") {
1051
- console.log(
1052
- "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
1053
- );
1054
- process.exit(args[0] ? 1 : 0);
1055
- }
1056
- materializeAgentKit(process.cwd());
1057
- console.log(
1058
- "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1059
- );
1060
- }
1061
-
1062
979
  // src/commands/add.ts
1063
980
  function flag2(args, name) {
1064
981
  const i = args.indexOf(name);
@@ -1096,71 +1013,69 @@ async function addCommand(args) {
1096
1013
  const data = entry?.data ?? "none";
1097
1014
  const envs = entry?.envs ?? ["beta", "prod"];
1098
1015
  const toolInfo = { target, data };
1099
- const dest = resolve6(process.cwd(), "tools", name);
1100
- 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`);
1101
1018
  const src = templateDir(lane, target);
1102
- if (existsSync6(src)) {
1019
+ if (existsSync5(src)) {
1103
1020
  cpSync2(src, dest, { recursive: true });
1104
1021
  const pkgPath = join(dest, "package.json");
1105
- if (existsSync6(pkgPath)) {
1106
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1022
+ if (existsSync5(pkgPath)) {
1023
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1107
1024
  pkg.name = name;
1108
- writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1025
+ writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1109
1026
  `);
1110
1027
  }
1111
1028
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1112
1029
  } else {
1113
1030
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1114
1031
  }
1115
- writeFileSync3(path, serializeConfig(next));
1032
+ writeFileSync2(path, serializeConfig(next));
1116
1033
  console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
1117
1034
  const cwd = process.cwd();
1118
1035
  const providers = providersForTool(toolInfo);
1119
- const infraDir = resolve6(cwd, "infra");
1036
+ const infraDir = resolve5(cwd, "infra");
1120
1037
  const mainTf = join(infraDir, "main.tf");
1121
- if (!existsSync6(mainTf)) {
1122
- mkdirSync3(infraDir, { recursive: true });
1123
- writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1038
+ if (!existsSync5(mainTf)) {
1039
+ mkdirSync2(infraDir, { recursive: true });
1040
+ writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1124
1041
  console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
1125
1042
  } else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
1126
1043
  console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
1127
1044
  }
1128
1045
  const toolTf = join(infraDir, `${name}.tf`);
1129
- if (existsSync6(toolTf)) {
1046
+ if (existsSync5(toolTf)) {
1130
1047
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1131
1048
  } else {
1132
- writeFileSync3(
1049
+ writeFileSync2(
1133
1050
  toolTf,
1134
1051
  emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
1135
1052
  );
1136
1053
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1137
1054
  }
1138
- 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}` : ""}:`);
1139
1061
  try {
1140
- const outcomes = await ensureTokensForTool(cwd, toolInfo, {
1141
- verify: !args.includes("--no-verify")
1142
- });
1143
- const missing = outcomes.filter((o) => o.outcome === "missing").map((o) => o.envVar);
1144
- if (missing.length) {
1145
- console.log(
1146
- `! missing token(s): ${missing.join(", ")} \u2014 set in .greenlight/secrets.env, then \`greenlight secrets sync\``
1147
- );
1148
- }
1062
+ await gatherSecrets(name, repo, flag2(args, "--env"));
1149
1063
  } catch (e) {
1150
- 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}` : ""}`);
1151
1066
  }
1152
1067
  }
1153
- materializeAgentKit(cwd, toolInfo);
1154
1068
  console.log(`
1155
- Next:
1069
+ Next:${gather ? "" : `
1070
+ greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : " --repo <owner/repo>"} # this tool's keys \u2192 GitHub`}
1156
1071
  review infra/${name}.tf, then commit + push \u2192 CI (infra.yml) runs \`terraform apply\`
1157
1072
  greenlight preview ${name} # local build + serve + verify`);
1158
1073
  }
1159
1074
 
1160
1075
  // src/commands/adopt.ts
1161
1076
  import { execFileSync as execFileSync2 } from "child_process";
1162
- import { cpSync as cpSync3, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync4 } from "fs";
1163
- 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";
1164
1079
  var REF = MODULE_REF;
1165
1080
  function flag3(args, name) {
1166
1081
  const i = args.indexOf(name);
@@ -1176,7 +1091,7 @@ function mergePackageJson(existing, repoName, vendor) {
1176
1091
  }
1177
1092
  function vendorDeps(vendorDir) {
1178
1093
  const out = {};
1179
- if (!existsSync7(vendorDir)) return out;
1094
+ if (!existsSync6(vendorDir)) return out;
1180
1095
  for (const f of readdirSync(vendorDir)) {
1181
1096
  if (!f.endsWith(".tgz")) continue;
1182
1097
  const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
@@ -1184,10 +1099,17 @@ function vendorDeps(vendorDir) {
1184
1099
  }
1185
1100
  return out;
1186
1101
  }
1187
- function starterVerifyConfig(lane) {
1188
- const spec = lane === "mcp" ? "{ mode: 'mcp', expectTools: [] }" : "{ mode: 'api', checks: [{ path: '/', status: 200 }] }";
1102
+ function starterVerifyConfig(lane, target) {
1103
+ const spec = lane === "mcp" ? "mode: 'mcp', expectTools: []" : "mode: 'api', checks: [{ path: '/', status: 200 }]";
1104
+ const logHint = target === "oci" ? "oci logging-search search-logs ... // the instance/container logs" : target === "vercel" ? "vercel logs <deployment-url> --token $VERCEL_API_TOKEN" : "wrangler tail --once // workers observability";
1189
1105
  return `// Greenlight verify spec \u2014 edit to assert this tool's real contract.
1190
- export default ${spec};
1106
+ export default {
1107
+ ${spec},
1108
+ // Telemetry-into-verify: a shell command run ONLY when this report FAILS; its last ~50 lines
1109
+ // attach to the report so the agent/CI sees the "why" in-loop. Best-effort (never fails the
1110
+ // gate). Uncomment + adjust:
1111
+ // logsOnFailure: '${logHint}',
1112
+ };
1191
1113
  `;
1192
1114
  }
1193
1115
  function infraTf(name, domain, lane, target, data, envs, slug) {
@@ -1379,26 +1301,8 @@ jobs:
1379
1301
  -F client_payload[sha]=\${{ github.sha }}
1380
1302
  `;
1381
1303
  }
1382
- function deployListenerYml(name, toolRepo) {
1383
- return `name: greenlight-deploy-${name}
1384
-
1385
- # Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
1386
- on:
1387
- repository_dispatch:
1388
- types: [deploy-${name}]
1389
- workflow_dispatch:
1390
-
1391
- permissions:
1392
- contents: read
1393
-
1394
- jobs:
1395
- deploy:
1396
- runs-on: ubuntu-latest
1397
- steps:
1398
- - uses: actions/checkout@v4
1399
- - uses: jdx/mise-action@v2
1400
- - run: pnpm install --frozen-lockfile
1401
- - run: pip install --quiet oci-cli
1304
+ function ociDeployAndVerifySteps(name) {
1305
+ return ` - run: pip install --quiet oci-cli
1402
1306
  - name: Deploy (resolve instance OCID by name -> restart -> re-pull GHCR image)
1403
1307
  env:
1404
1308
  # The OCI CLI reuses the SAME TF_VAR_OCI_* secrets the apply uses \u2014 one secret set.
@@ -1431,7 +1335,33 @@ jobs:
1431
1335
  # The deploy "succeeds" only if the NEW image is actually serving. verify has a built-in
1432
1336
  # readiness wait (re-pull + container start). A failure here fails the job \u2192 the status
1433
1337
  # posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
1434
- run: pnpm exec greenlight verify ${name} --env prod
1338
+ run: pnpm exec greenlight verify ${name} --env prod`;
1339
+ }
1340
+ function deployListenerYml(name, toolRepo) {
1341
+ return `name: greenlight-deploy-${name}
1342
+
1343
+ # Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
1344
+ on:
1345
+ repository_dispatch:
1346
+ types: [deploy-${name}]
1347
+ workflow_dispatch:
1348
+
1349
+ permissions:
1350
+ contents: read
1351
+
1352
+ # Share the self-heal workflow's group so a deploy and a remediation never run at the same time.
1353
+ concurrency:
1354
+ group: deploy-${name}
1355
+ cancel-in-progress: false
1356
+
1357
+ jobs:
1358
+ deploy:
1359
+ runs-on: ubuntu-latest
1360
+ steps:
1361
+ - uses: actions/checkout@v4
1362
+ - uses: jdx/mise-action@v2
1363
+ - run: pnpm install --frozen-lockfile
1364
+ ${ociDeployAndVerifySteps(name)}
1435
1365
  - name: Report status back to ${toolRepo}
1436
1366
  if: \${{ always() && github.event.client_payload.sha != '' }}
1437
1367
  env:
@@ -1445,6 +1375,65 @@ jobs:
1445
1375
  -f description="\${{ job.status }}"
1446
1376
  `;
1447
1377
  }
1378
+ function remediateYml(name) {
1379
+ return `name: greenlight-remediate-${name}
1380
+
1381
+ # Auto-heal: the keepalive Worker dispatches remediate-${name} when ${name} (oci) is unreachable.
1382
+ on:
1383
+ repository_dispatch:
1384
+ types: [remediate-${name}]
1385
+ workflow_dispatch:
1386
+
1387
+ permissions:
1388
+ contents: read
1389
+ issues: write
1390
+
1391
+ # Same group as greenlight-deploy-${name}: a self-heal never overlaps a deploy or another heal, and
1392
+ # re-applying an already-healthy instance is idempotent (no diff) \u2014 anti-flap with no extra state.
1393
+ concurrency:
1394
+ group: deploy-${name}
1395
+ cancel-in-progress: false
1396
+
1397
+ jobs:
1398
+ remediate:
1399
+ runs-on: ubuntu-latest
1400
+ steps:
1401
+ - uses: actions/checkout@v4
1402
+ - uses: jdx/mise-action@v2
1403
+ - run: pnpm install --frozen-lockfile
1404
+ - uses: hashicorp/setup-terraform@v3
1405
+ with:
1406
+ terraform_version: '~1.10'
1407
+ terraform_wrapper: false
1408
+ - name: Re-apply the instance (recreate it if OCI idle-reclaimed the Always-Free box)
1409
+ env:
1410
+ TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
1411
+ TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
1412
+ TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
1413
+ TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
1414
+ TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
1415
+ TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
1416
+ TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
1417
+ run: |
1418
+ if [ -z "$TF_TOKEN_app_terraform_io" ]; then
1419
+ echo "::warning::no TF_API_TOKEN \u2014 skipping re-apply; will still attempt a restart below"
1420
+ exit 0
1421
+ fi
1422
+ terraform -chdir=infra init -input=false
1423
+ # -target pulls in the instance's deps (the ${name}_network module) automatically.
1424
+ terraform -chdir=infra apply -input=false -auto-approve -target=module.${name}_instance
1425
+ ${ociDeployAndVerifySteps(name)}
1426
+ - name: Escalate if the self-heal failed
1427
+ if: \${{ failure() }}
1428
+ env:
1429
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1430
+ run: |
1431
+ gh issue create --repo \${{ github.repository }} \\
1432
+ --title "remediate-${name}: self-heal FAILED" \\
1433
+ --body "Automatic remediation for ${name} (reason: \${{ github.event.client_payload.reason }}) did not bring prod back. Manual attention needed." \\
1434
+ --label keepalive || true
1435
+ `;
1436
+ }
1448
1437
  function verifyWorkflowYml(name) {
1449
1438
  return `name: greenlight-verify
1450
1439
 
@@ -1490,12 +1479,16 @@ function nextVerifyConfig(name) {
1490
1479
  // Unit tests belong in this repo's PR CI; to also gate the deploy on them, add
1491
1480
  // { mode: 'test', command: 'pnpm test' } + a tolerant deps-install step in greenlight-verify.yml.
1492
1481
  const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
1482
+ // Telemetry-into-verify: on a FAILED report, fetch the Vercel deployment's runtime logs and attach
1483
+ // the tail to the report (best-effort, never fails the gate). Needs VERCEL_API_TOKEN in CI.
1484
+ const logsOnFailure = 'vercel logs "$DEPLOYMENT_URL" --token "$VERCEL_API_TOKEN" 2>&1 || true';
1493
1485
  const api = bypass
1494
1486
  ? {
1495
1487
  mode: 'api',
1496
1488
  checks: [{ path: '/', status: 200, requestHeaders: { 'x-vercel-protection-bypass': bypass } }],
1489
+ logsOnFailure,
1497
1490
  }
1498
- : { mode: 'api', checks: [{ path: '/', status: 401 }] };
1491
+ : { mode: 'api', checks: [{ path: '/', status: 401 }], logsOnFailure };
1499
1492
  const agentWeb = process.env.ANTHROPIC_API_KEY
1500
1493
  ? [
1501
1494
  {
@@ -1515,12 +1508,12 @@ export default [api, ...agentWeb];
1515
1508
  `;
1516
1509
  }
1517
1510
  function writeIfAbsent(path, contents, label) {
1518
- if (existsSync7(path)) {
1511
+ if (existsSync6(path)) {
1519
1512
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
1520
1513
  return;
1521
1514
  }
1522
- mkdirSync4(resolve7(path, ".."), { recursive: true });
1523
- writeFileSync4(path, contents);
1515
+ mkdirSync3(resolve6(path, ".."), { recursive: true });
1516
+ writeFileSync3(path, contents);
1524
1517
  console.log(`\u2714 ${label}`);
1525
1518
  }
1526
1519
  async function adoptCommand(args) {
@@ -1556,10 +1549,10 @@ async function adoptWrapper(ctx) {
1556
1549
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1557
1550
  const cwd = process.cwd();
1558
1551
  const toolRel = `tools/${name}`;
1559
- const dest = resolve7(cwd, toolRel);
1552
+ const dest = resolve6(cwd, toolRel);
1560
1553
  console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
1561
1554
  `);
1562
- if (!existsSync7(dest)) {
1555
+ if (!existsSync6(dest)) {
1563
1556
  try {
1564
1557
  execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
1565
1558
  console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
@@ -1584,7 +1577,7 @@ async function adoptWrapper(ctx) {
1584
1577
  external: true,
1585
1578
  adopted: true
1586
1579
  });
1587
- writeFileSync4(regPath, serializeConfig(nextReg));
1580
+ writeFileSync3(regPath, serializeConfig(nextReg));
1588
1581
  console.log(
1589
1582
  `\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
1590
1583
  );
@@ -1597,12 +1590,12 @@ async function adoptWrapper(ctx) {
1597
1590
  if (target !== "vercel") {
1598
1591
  writeIfAbsent(
1599
1592
  join2(cwd, `verify/${name}.config.ts`),
1600
- starterVerifyConfig(lane),
1593
+ starterVerifyConfig(lane, target),
1601
1594
  `verify/${name}.config.ts`
1602
1595
  );
1603
1596
  }
1604
1597
  const providers = providersForTool({ target, data });
1605
- if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1598
+ if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1606
1599
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
1607
1600
  }
1608
1601
  materializeAgentKit(dest, { target, data });
@@ -1614,6 +1607,11 @@ async function adoptWrapper(ctx) {
1614
1607
  deployListenerYml(name, slug),
1615
1608
  `.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
1616
1609
  );
1610
+ writeIfAbsent(
1611
+ join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
1612
+ remediateYml(name),
1613
+ `.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
1614
+ );
1617
1615
  writeIfAbsent(
1618
1616
  join2(dest, ".github/workflows/greenlight-build.yml"),
1619
1617
  containerBuildYml(name, wrapperSlug),
@@ -1648,9 +1646,9 @@ Next:
1648
1646
  }
1649
1647
  async function adoptStandalone(ctx) {
1650
1648
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1651
- const repo = resolve7(process.cwd(), repoArg);
1652
- if (!existsSync7(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1653
- const regVendor = resolve7(process.cwd(), "vendor");
1649
+ const repo = resolve6(process.cwd(), repoArg);
1650
+ if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1651
+ const regVendor = resolve6(process.cwd(), "vendor");
1654
1652
  const vendor = vendorDeps(regVendor);
1655
1653
  if (Object.keys(vendor).length === 0) {
1656
1654
  throw new Error(
@@ -1668,15 +1666,15 @@ async function adoptStandalone(ctx) {
1668
1666
  );
1669
1667
  const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
1670
1668
  const pkgPath = join2(repo, "package.json");
1671
- const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
1672
- writeFileSync4(
1669
+ const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
1670
+ writeFileSync3(
1673
1671
  pkgPath,
1674
1672
  `${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
1675
1673
  `
1676
1674
  );
1677
1675
  console.log("\u2714 package.json (merged framework deps + overrides)");
1678
1676
  const repoVendor = join2(repo, "vendor");
1679
- mkdirSync4(repoVendor, { recursive: true });
1677
+ mkdirSync3(repoVendor, { recursive: true });
1680
1678
  for (const f of readdirSync(regVendor)) {
1681
1679
  if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
1682
1680
  }
@@ -1710,7 +1708,7 @@ async function adoptStandalone(ctx) {
1710
1708
  external: true,
1711
1709
  adopted: true
1712
1710
  });
1713
- writeFileSync4(regPath, serializeConfig(nextReg));
1711
+ writeFileSync3(regPath, serializeConfig(nextReg));
1714
1712
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
1715
1713
  console.log(`
1716
1714
  Next (in the adopted repo):
@@ -1723,20 +1721,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
1723
1721
  }
1724
1722
  function addGreenlightScript(dir) {
1725
1723
  const pkgPath = join2(dir, "package.json");
1726
- if (!existsSync7(pkgPath)) {
1724
+ if (!existsSync6(pkgPath)) {
1727
1725
  console.log(
1728
1726
  "\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
1729
1727
  );
1730
1728
  return;
1731
1729
  }
1732
- const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
1730
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1733
1731
  pkg.scripts = { ...pkg.scripts ?? {} };
1734
1732
  if (pkg.scripts.greenlight) {
1735
1733
  console.log("\xB7 package.json already has a greenlight script");
1736
1734
  return;
1737
1735
  }
1738
1736
  pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
1739
- writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
1737
+ writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1740
1738
  `);
1741
1739
  console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
1742
1740
  }
@@ -1879,10 +1877,10 @@ async function deployCommand(args) {
1879
1877
  }
1880
1878
 
1881
1879
  // src/commands/doctor.ts
1882
- import { existsSync as existsSync8 } from "fs";
1880
+ import { existsSync as existsSync7 } from "fs";
1883
1881
  import { join as join4 } from "path";
1884
1882
  function dirCheck(label, dir) {
1885
- return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1883
+ return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1886
1884
  }
1887
1885
  function runDoctor(config, root) {
1888
1886
  const checks = [];
@@ -1904,8 +1902,8 @@ function runDoctor(config, root) {
1904
1902
  const vc = join4(dir, "verify.config.ts");
1905
1903
  checks.push({
1906
1904
  name: `${t.name}: verify.config.ts`,
1907
- status: existsSync8(vc) ? "ok" : "warn",
1908
- detail: existsSync8(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1905
+ status: existsSync7(vc) ? "ok" : "warn",
1906
+ detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1909
1907
  });
1910
1908
  }
1911
1909
  }
@@ -1951,10 +1949,180 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
1951
1949
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1952
1950
  import { resolve as resolve8 } from "path";
1953
1951
  import { createInterface as createInterface3 } from "readline/promises";
1952
+
1953
+ // src/tokens.ts
1954
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
1955
+ import { resolve as resolve7 } from "path";
1956
+ import { createInterface as createInterface2 } from "readline/promises";
1957
+ var SECRETS_DIR = ".greenlight";
1958
+ var SECRETS_FILE = "secrets.env";
1959
+ function presentEnv(cwd) {
1960
+ const out = {};
1961
+ const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
1962
+ if (existsSync8(p)) {
1963
+ for (const { key, value } of parseSecretsEnv(readFileSync5(p, "utf8"))) out[key] = value;
1964
+ }
1965
+ for (const [k, v] of Object.entries(process.env)) {
1966
+ if (v !== void 0 && !(k in out)) out[k] = v;
1967
+ }
1968
+ return out;
1969
+ }
1970
+ function upsertSecret(cwd, key, value) {
1971
+ const dir = resolve7(cwd, SECRETS_DIR);
1972
+ mkdirSync4(dir, { recursive: true });
1973
+ const p = resolve7(dir, SECRETS_FILE);
1974
+ const lines = existsSync8(p) ? readFileSync5(p, "utf8").split("\n") : [];
1975
+ const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
1976
+ if (idx >= 0) lines[idx] = `${key}=${value}`;
1977
+ else {
1978
+ while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
1979
+ lines.push(`${key}=${value}`);
1980
+ }
1981
+ writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
1982
+ `, { mode: 384 });
1983
+ }
1984
+ async function ensureTokensForTool(cwd, tool, opts = {}) {
1985
+ const doVerify = opts.verify !== false;
1986
+ const interactive = Boolean(process.stdin.isTTY);
1987
+ const env = presentEnv(cwd);
1988
+ const results = [];
1989
+ const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
1990
+ try {
1991
+ for (const spec of tokensForTool(tool)) {
1992
+ let value = env[spec.envVar];
1993
+ if (value) {
1994
+ results.push({ envVar: spec.envVar, outcome: "present" });
1995
+ } else if (rl) {
1996
+ console.log(`
1997
+ ${spec.envVar} \u2014 ${spec.label}`);
1998
+ if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
1999
+ const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
2000
+ if (!entered) {
2001
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2002
+ continue;
2003
+ }
2004
+ upsertSecret(cwd, spec.envVar, entered);
2005
+ env[spec.envVar] = entered;
2006
+ value = entered;
2007
+ results.push({ envVar: spec.envVar, outcome: "entered" });
2008
+ } else {
2009
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2010
+ continue;
2011
+ }
2012
+ if (value && doVerify && spec.verify) {
2013
+ let check;
2014
+ try {
2015
+ check = await spec.verify(value, env);
2016
+ } catch (e) {
2017
+ check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
2018
+ }
2019
+ const last = results[results.length - 1];
2020
+ if (last) last.verify = check;
2021
+ if (!check.ok && !spec.optional) {
2022
+ throw new Error(
2023
+ `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
2024
+ );
2025
+ }
2026
+ }
2027
+ }
2028
+ } finally {
2029
+ rl?.close();
2030
+ }
2031
+ return results;
2032
+ }
2033
+
2034
+ // src/commands/init.ts
1954
2035
  function flag5(args, name) {
1955
2036
  const i = args.indexOf(name);
1956
2037
  return i >= 0 ? args[i + 1] : void 0;
1957
2038
  }
2039
+ var NPM_DEP = `^${MODULE_REF.replace(/^v/, "")}`;
2040
+ function wrapperPackageJson(name) {
2041
+ return `${JSON.stringify(
2042
+ {
2043
+ name,
2044
+ private: true,
2045
+ type: "module",
2046
+ scripts: { greenlight: "greenlight" },
2047
+ dependencies: { "@rtrentjones/greenlight": NPM_DEP }
2048
+ },
2049
+ null,
2050
+ 2
2051
+ )}
2052
+ `;
2053
+ }
2054
+ var WRAPPER_GITIGNORE = `# Greenlight wrapper
2055
+ node_modules/
2056
+ .greenlight/ # gathered tokens \u2014 never committed
2057
+ .terraform/
2058
+ *.tfplan
2059
+ tf.plan
2060
+ dist/
2061
+ `;
2062
+ var WRAPPER_MISE = `[tools]
2063
+ node = "24"
2064
+ pnpm = "10.12.1"
2065
+ `;
2066
+ function wrapperInfraYml() {
2067
+ return `name: infra
2068
+
2069
+ # Apply the wrapper's Terraform on push to main (paths: infra/**). State + locking are in HCP
2070
+ # Terraform (set the cloud{} block in infra/main.tf); the run happens here with provider creds
2071
+ # from GitHub Actions secrets (populate them per tool via \`greenlight secrets gather\`).
2072
+ on:
2073
+ push:
2074
+ branches: [main]
2075
+ paths: ['infra/**']
2076
+ workflow_dispatch:
2077
+
2078
+ permissions:
2079
+ contents: read
2080
+
2081
+ concurrency:
2082
+ group: infra
2083
+ cancel-in-progress: false # never interrupt an in-flight apply
2084
+
2085
+ jobs:
2086
+ apply:
2087
+ runs-on: ubuntu-latest
2088
+ env:
2089
+ TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
2090
+ GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
2091
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
2092
+ TF_VAR_cloudflare_zone_id: \${{ secrets.TF_VAR_CLOUDFLARE_ZONE_ID }}
2093
+ TF_VAR_cloudflare_account_id: \${{ secrets.TF_VAR_CLOUDFLARE_ACCOUNT_ID }}
2094
+ # vercel (target: vercel tools)
2095
+ VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
2096
+ # supabase (data: supabase tools)
2097
+ SUPABASE_ACCESS_TOKEN: \${{ secrets.SUPABASE_ACCESS_TOKEN }}
2098
+ TF_VAR_supabase_database_password: \${{ secrets.TF_VAR_SUPABASE_DATABASE_PASSWORD }}
2099
+ # oci (target: oci tools) \u2014 VCN/subnet/AD are IaC; only auth (+ optional compartment) here
2100
+ TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
2101
+ TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
2102
+ TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
2103
+ TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
2104
+ TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
2105
+ TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
2106
+ steps:
2107
+ - uses: actions/checkout@v4
2108
+ - uses: hashicorp/setup-terraform@v3
2109
+ with:
2110
+ terraform_version: '~1.10'
2111
+ terraform_wrapper: false
2112
+ - run: terraform -chdir=infra init -input=false
2113
+ - run: terraform -chdir=infra plan -input=false -out=tf.plan
2114
+ - run: terraform -chdir=infra apply -input=false tf.plan
2115
+ `;
2116
+ }
2117
+ function scaffoldIfAbsent(path, contents, label) {
2118
+ if (existsSync9(path)) {
2119
+ console.log(`\xB7 ${label} exists \u2014 left as-is`);
2120
+ return;
2121
+ }
2122
+ mkdirSync5(resolve8(path, ".."), { recursive: true });
2123
+ writeFileSync5(path, contents);
2124
+ console.log(`\u2714 wrote ${label}`);
2125
+ }
1958
2126
  var TOKEN_FLAGS = {
1959
2127
  "--cf-token": "CLOUDFLARE_API_TOKEN",
1960
2128
  "--github-token": "GITHUB_TOKEN",
@@ -1979,6 +2147,16 @@ async function initCommand(args) {
1979
2147
  }
1980
2148
  writeFileSync5(configPath, scaffoldConfig(domain));
1981
2149
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2150
+ const repoName = domain.replace(/\./g, "-");
2151
+ scaffoldIfAbsent(
2152
+ resolve8(cwd, ".github/workflows/infra.yml"),
2153
+ wrapperInfraYml(),
2154
+ ".github/workflows/infra.yml (HCP-backed terraform apply on push)"
2155
+ );
2156
+ scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2157
+ scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2158
+ scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2159
+ scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
1982
2160
  const secrets = [];
1983
2161
  for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
1984
2162
  const v = flag5(args, f);
@@ -2012,10 +2190,11 @@ async function initCommand(args) {
2012
2190
  }
2013
2191
  console.log(`
2014
2192
  Next:
2015
- greenlight add <name> --lane mcp --target oci # scaffold a tool${pushed ? "" : "\n greenlight secrets sync # push tokens to GitHub Actions"}
2016
- greenlight doctor # check consistency
2017
- terraform -chdir=infra apply # branches/protection/DNS (module "repo"/"tool")
2018
- greenlight deploy blog --env prod # first live deploy`);
2193
+ 1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
2194
+ # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
2195
+ 2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state-r2.md
2196
+ 3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
2197
+ 4. greenlight verify <name> --env prod | greenlight doctor`);
2019
2198
  }
2020
2199
 
2021
2200
  // src/commands/preview.ts
@@ -2024,6 +2203,7 @@ import { resolve as resolve10 } from "path";
2024
2203
  import { setTimeout as sleep } from "timers/promises";
2025
2204
 
2026
2205
  // src/commands/verify.ts
2206
+ import { spawnSync } from "child_process";
2027
2207
  import { resolve as resolve9 } from "path";
2028
2208
  function defaultSpec(lane) {
2029
2209
  switch (lane) {
@@ -2043,6 +2223,37 @@ function printReport(report) {
2043
2223
  }
2044
2224
  console.log(`
2045
2225
  ${report.pass ? "\u2714 PASS" : "\u2718 FAIL"}`);
2226
+ if (!report.pass && report.logs) {
2227
+ console.log(`
2228
+ --- recent logs (${report.mode}) ---
2229
+ ${report.logs}
2230
+ --- end logs ---`);
2231
+ }
2232
+ }
2233
+ var LOG_TAIL_LINES = 50;
2234
+ function attachFailureLogs(reports, specs, toolDir) {
2235
+ reports.forEach((report, i) => {
2236
+ if (report.pass) return;
2237
+ const cmd = specs[i]?.logsOnFailure;
2238
+ if (!cmd) {
2239
+ report.logs = "(no logsOnFailure configured for this spec)";
2240
+ return;
2241
+ }
2242
+ try {
2243
+ const res = spawnSync(cmd, {
2244
+ shell: true,
2245
+ cwd: toolDir,
2246
+ timeout: 3e4,
2247
+ encoding: "utf8",
2248
+ maxBuffer: 10 * 1024 * 1024
2249
+ });
2250
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd();
2251
+ const tail = out.split("\n").slice(-LOG_TAIL_LINES).join("\n");
2252
+ report.logs = tail || `(logsOnFailure produced no output${res.error ? `: ${res.error.message}` : ""})`;
2253
+ } catch (e) {
2254
+ report.logs = `(log fetch failed: ${e instanceof Error ? e.message : String(e)})`;
2255
+ }
2256
+ });
2046
2257
  }
2047
2258
  function flag6(args, name) {
2048
2259
  const i = args.indexOf(name);
@@ -2061,6 +2272,7 @@ async function verifyCommand(args) {
2061
2272
  reachableTimeoutMs: waitMs,
2062
2273
  toolDir: process.cwd()
2063
2274
  });
2275
+ attachFailureLogs(reports2, specs2, process.cwd());
2064
2276
  for (const report of reports2) printReport(report);
2065
2277
  const pass2 = allPass(reports2);
2066
2278
  if (reports2.length > 1)
@@ -2098,6 +2310,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
2098
2310
  }
2099
2311
  const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2100
2312
  const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
2313
+ attachFailureLogs(reports, specs, toolDir);
2101
2314
  for (const report of reports) printReport(report);
2102
2315
  const pass = allPass(reports);
2103
2316
  if (reports.length > 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "@anthropic-ai/sdk": "^0.69.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@rtrentjones/greenlight-adapters": "0.2.4",
35
34
  "@rtrentjones/greenlight-loop": "0.2.4",
36
35
  "@rtrentjones/greenlight-shared": "0.2.4",
36
+ "@rtrentjones/greenlight-adapters": "0.2.4",
37
37
  "@rtrentjones/greenlight-verify": "0.2.4"
38
38
  },
39
39
  "scripts": {