@rtrentjones/greenlight 0.3.1 → 0.4.1

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.
@@ -19,9 +19,10 @@ One token, these scopes (a missing scope took down a live apply more than once):
19
19
  - **Account · Cloudflare Tunnel · Edit** — only if a tool uses `target: oci` (the cloudflared
20
20
  tunnel). Without it, the tunnel resource fails with **403 Forbidden** on `cfd_tunnel` at apply.
21
21
 
22
- Create at dash → My Profile → API Tokens → Custom Token. Store in `.greenlight/secrets.env`
23
- (gitignored) and push to GitHub Actions with `greenlight secrets sync`. `greenlight add`
24
- verifies it against `/user/tokens/verify` (status must be `active`) before you commit.
22
+ Create at dash → My Profile → API Tokens → Custom Token. Push it straight to GitHub Actions
23
+ with `greenlight secrets gather` (or `gh secret set CLOUDFLARE_API_TOKEN`) Greenlight keeps
24
+ no local secret file. `greenlight add` verifies it against `/user/tokens/verify` (status must
25
+ be `active`) before you commit.
25
26
 
26
27
  ## Terraform modules
27
28
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: provider-github
3
- description: How GitHub works in a Greenlight setup — secrets sync target (Actions secrets/environments), the repo/branch/protection Terraform module, the develop→main flow, and OIDC-over-PAT preference. Use when syncing tokens, wiring branch protection/environments, or debugging gh/secrets/CI auth.
3
+ description: How GitHub works in a Greenlight setup — the single secret store (Actions secrets/environments), the repo/branch/protection Terraform module, the develop→main flow, and OIDC-over-PAT preference. Use when setting tokens, wiring branch protection/environments, or debugging gh/secrets/CI auth.
4
4
  ---
5
5
 
6
6
  # provider-github
@@ -32,11 +32,12 @@ pushes each to the right repo; see docs/provider-tokens.md):
32
32
  Provider creds (OCI/Cloudflare/…) live **only in the wrapper**; the tool repo holds just the
33
33
  dispatch PAT (its build pushes to GHCR with the built-in `github.token`).
34
34
 
35
- ## Secrets sync
35
+ ## Setting secrets
36
36
 
37
- `greenlight secrets sync [--repo o/r] [--env <env>]` pushes `.greenlight/secrets.env` to the
38
- repo's Actions secrets via `gh` (values piped on stdin never in argv or logs). Run
39
- `gh auth login` first. This is the "init writes to provider stores" piece.
37
+ GitHub Actions secrets are the **single** secret store Greenlight keeps no local secret file.
38
+ `greenlight secrets gather <tool> [--repo o/r] [--env <env>]` prompts the tool's tokens (and the
39
+ always-on base tokens) with hidden input and pipes them straight to `gh secret set` (never on
40
+ disk, never in argv or logs). Run `gh auth login` first. `gh secret set` is the manual alternative.
40
41
 
41
42
  ## Terraform module — `infra/modules/repo`
42
43
 
@@ -12,7 +12,8 @@ name/region are replace-forcing, so the module sets `ignore_changes` to protect
12
12
 
13
13
  ## Token — `SUPABASE_ACCESS_TOKEN`
14
14
 
15
- Dashboard → Account → Access Tokens (Management API). Store in `.greenlight/secrets.env`;
15
+ Dashboard → Account → Access Tokens (Management API). Push it straight to GitHub Actions with
16
+ `greenlight secrets gather` (or `gh secret set`) — Greenlight keeps no local secret file.
16
17
  `greenlight add` verifies it against `/v1/projects` (HTTP 200). The DB password
17
18
  (`TF_VAR_supabase_database_password`) is only used if the project is recreated — ignored on
18
19
  import, so `import-placeholder` is fine for an existing project.
@@ -13,7 +13,8 @@ build). The wrapper owns infra; the tool repo owns deploys.
13
13
  ## Token — `VERCEL_API_TOKEN`
14
14
 
15
15
  Account → Settings → Tokens. **Scope it to the team** that owns the project. The Terraform
16
- `vercel` provider also takes `team` (the `team_…` id). Store in `.greenlight/secrets.env`;
16
+ `vercel` provider also takes `team` (the `team_…` id). Push it straight to GitHub Actions with
17
+ `greenlight secrets gather` (or `gh secret set`) — Greenlight keeps no local secret file.
17
18
  `greenlight add` verifies it against `/v2/user` (HTTP 200) before commit.
18
19
 
19
20
  ## Terraform module — `infra/modules/vercel`
package/dist/bin.js CHANGED
@@ -15,8 +15,8 @@ import "./chunk-XWTOJHLV.js";
15
15
  import "./chunk-QFKE5JKC.js";
16
16
 
17
17
  // src/commands/add.ts
18
- import { cpSync as cpSync2, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync, writeFileSync as writeFileSync3 } from "fs";
19
- import { join, resolve as resolve6 } from "path";
18
+ import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
19
+ import { join, resolve as resolve5 } from "path";
20
20
 
21
21
  // src/agent-deploy.ts
22
22
  function emitAgentDeployWorkflow(name, domain) {
@@ -62,6 +62,12 @@ jobs:
62
62
  RUN_TOKEN: \${{ secrets.RUN_TOKEN }}
63
63
  run: |
64
64
  cd tools/${name}
65
+ # Account id as code: resolve the (non-secret) account id from the domain's zone and inject
66
+ # it into wrangler.toml \u2014 wrangler can't call /memberships to auto-discover it with a scoped
67
+ # token. Derived, so the repo keeps the REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID placeholder.
68
+ ACCT=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=${domain}" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[0].account.id // empty')
69
+ if [ -z "$ACCT" ]; then echo "::error::could not resolve the Cloudflare account id for ${domain} (token needs Zone:Read?)"; exit 1; fi
70
+ sed -i "s/REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID/$ACCT/g" wrangler.toml
65
71
  # KV namespace as code: find-or-create the STATE namespace (idempotent), then inject its id
66
72
  # into wrangler.toml for this deploy. The id is non-secret + derived, so the repo keeps the
67
73
  # REPLACE_WITH_KV_NAMESPACE_ID placeholder \u2014 no manual create, no hardcoded id.
@@ -93,19 +99,6 @@ jobs:
93
99
  run: echo "Missing CLOUDFLARE_API_TOKEN or GEMINI_API_KEY \u2014 ${name} deploy skipped."
94
100
  `;
95
101
  }
96
- async function resolveCloudflareAccountId(domain, token) {
97
- try {
98
- const res = await fetch(
99
- `https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(domain)}`,
100
- { headers: { Authorization: `Bearer ${token}` } }
101
- );
102
- if (!res.ok) return null;
103
- const data = await res.json();
104
- return data.result?.[0]?.account?.id ?? null;
105
- } catch {
106
- return null;
107
- }
108
- }
109
102
 
110
103
  // src/asset-paths.ts
111
104
  import { existsSync } from "fs";
@@ -489,7 +482,7 @@ var PACKS = [
489
482
  id: "github",
490
483
  name: "GitHub",
491
484
  always: true,
492
- // secrets sync + repo/branch infra
485
+ // the single secret store (Actions secrets) + repo/branch infra
493
486
  appliesTo: () => true,
494
487
  guide: "docs/provider-tokens.md \u2014 GitHub (gh auth, or a fine-grained PAT)",
495
488
  setupUrl: "https://github.com/settings/personal-access-tokens/new",
@@ -590,7 +583,7 @@ function tokensForTool(tool) {
590
583
  }
591
584
 
592
585
  // src/version.ts
593
- var MODULE_REF = "v0.3.1";
586
+ var MODULE_REF = "v0.4.1";
594
587
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
595
588
  function moduleSource(module, ref = MODULE_REF) {
596
589
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -915,27 +908,104 @@ function providersForTool(tool) {
915
908
  return out;
916
909
  }
917
910
 
918
- // src/tokens.ts
919
- import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
920
- import { resolve as resolve4 } from "path";
921
- import { createInterface as createInterface2 } from "readline/promises";
922
-
923
- // src/commands/secrets.ts
924
- import { execFileSync } from "child_process";
925
- import { existsSync as existsSync3, readFileSync } from "fs";
911
+ // src/commands/agent.ts
912
+ import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
926
913
  import { resolve as resolve3 } from "path";
927
- import { createInterface } from "readline";
928
- function parseSecretsEnv(text) {
929
- const out = [];
930
- for (const raw of text.split("\n")) {
931
- const line = raw.trim();
932
- if (line === "" || line.startsWith("#")) continue;
933
- const eq = line.indexOf("=");
934
- if (eq <= 0) continue;
935
- out.push({ key: line.slice(0, eq).trim(), value: line.slice(eq + 1) });
914
+
915
+ // src/agent-kit.ts
916
+ function recommendedMcp(tool) {
917
+ return mcpForTool(tool);
918
+ }
919
+ function mergeMcpServers(existing, add) {
920
+ const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
921
+ for (const [name, val] of Object.entries(add)) {
922
+ if (out.mcpServers[name]) continue;
923
+ out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
936
924
  }
937
925
  return out;
938
926
  }
927
+
928
+ // src/commands/agent.ts
929
+ var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
930
+
931
+ This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
932
+ tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
933
+ branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
934
+ push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
935
+ Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
936
+ pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
937
+
938
+ Agentic kit:
939
+ - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
940
+ - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
941
+ Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
942
+ - Best-practice skills (one-time, user scope):
943
+ \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
944
+ `;
945
+ function materializeAgentKit(dir, tool) {
946
+ const src = skillAssetDir();
947
+ if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
948
+ const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
949
+ mkdirSync(dest, { recursive: true });
950
+ cpSync(src, dest, { recursive: true });
951
+ console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
952
+ for (const pack of packsForTool(tool)) {
953
+ if (!pack.skill) continue;
954
+ const skillSrc = skillAssetDir(pack.skill);
955
+ if (!existsSync3(skillSrc)) continue;
956
+ const skillDest = resolve3(dir, ".claude/skills", pack.skill);
957
+ mkdirSync(skillDest, { recursive: true });
958
+ cpSync(skillSrc, skillDest, { recursive: true });
959
+ console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
960
+ }
961
+ const mcpPath = resolve3(dir, ".mcp.json");
962
+ const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
963
+ const servers = recommendedMcp(tool);
964
+ writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
965
+ `);
966
+ console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
967
+ const claudePath = resolve3(dir, "CLAUDE.md");
968
+ const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
969
+ const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
970
+ if (existing.includes(marker)) {
971
+ console.log("\xB7 CLAUDE.md already has the loop block");
972
+ } else {
973
+ writeFileSync(claudePath, existing ? `${existing.trimEnd()}
974
+
975
+ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
976
+ console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
977
+ }
978
+ }
979
+ async function agentCommand(args) {
980
+ if (args[0] !== "sync") {
981
+ console.log(
982
+ "usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
983
+ );
984
+ process.exit(args[0] ? 1 : 0);
985
+ }
986
+ const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
987
+ if (name) {
988
+ const { config } = await loadManifest();
989
+ const entry = resolveEntry(config, name);
990
+ const dir = resolve3(process.cwd(), entry.dir ?? ".");
991
+ materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
992
+ console.log(
993
+ `
994
+ Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
995
+ );
996
+ return;
997
+ }
998
+ materializeAgentKit(process.cwd());
999
+ console.log(
1000
+ "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1001
+ );
1002
+ }
1003
+
1004
+ // src/commands/secrets.ts
1005
+ import { execFileSync } from "child_process";
1006
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
1007
+ import { resolve as resolve4 } from "path";
1008
+ import { createInterface } from "readline";
939
1009
  function parseOciConfig(text) {
940
1010
  const out = {};
941
1011
  for (const raw of text.split("\n")) {
@@ -949,7 +1019,7 @@ function parseOciConfig(text) {
949
1019
  return out;
950
1020
  }
951
1021
  function ociPrefill(configPath, keyPath) {
952
- const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
1022
+ const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
953
1023
  const map = /* @__PURE__ */ new Map();
954
1024
  const set = (k, v) => {
955
1025
  if (v) map.set(k, v);
@@ -959,8 +1029,8 @@ function ociPrefill(configPath, keyPath) {
959
1029
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
960
1030
  set("TF_VAR_OCI_REGION", cfg.region);
961
1031
  const pem = keyPath ?? cfg.key_file;
962
- if (pem && existsSync3(pem)) {
963
- map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
1032
+ if (pem && existsSync4(pem)) {
1033
+ map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
964
1034
  } else if (pem) {
965
1035
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
966
1036
  }
@@ -987,49 +1057,17 @@ function detectRepo(cwd) {
987
1057
  return null;
988
1058
  }
989
1059
  }
990
- function syncSecrets(opts) {
991
- const repo = opts.repo ?? detectRepo(opts.cwd);
992
- if (!repo) {
993
- throw new Error(
994
- "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
995
- );
996
- }
997
- const path = resolve3(opts.cwd, ".greenlight/secrets.env");
998
- if (!existsSync3(path)) {
999
- throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
1000
- }
1001
- const entries = parseSecretsEnv(readFileSync(path, "utf8"));
1002
- const target = opts.env ? `env "${opts.env}"` : "repo";
1003
- for (const { key, value } of entries) {
1004
- const ghArgs = ["secret", "set", key, "--repo", repo];
1005
- if (opts.env) ghArgs.push("--env", opts.env);
1006
- try {
1007
- execFileSync("gh", ghArgs, { input: value });
1008
- } catch (e) {
1009
- const err = e;
1010
- if (err.code === "ENOENT") {
1011
- throw new Error("the GitHub CLI `gh` is required \u2014 install it and run `gh auth login`");
1012
- }
1013
- const detail = err.stderr?.toString().trim();
1014
- throw new Error(
1015
- `failed to set ${key}${detail ? `: ${detail}` : " (check `gh auth status`)"}`
1016
- );
1017
- }
1018
- console.log(`\u2714 set ${key} \u2192 ${repo} ${target}`);
1019
- }
1020
- return { repo, count: entries.length };
1021
- }
1022
1060
  function hiddenPrompter() {
1023
1061
  const tty = Boolean(process.stdin.isTTY);
1024
1062
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
1025
1063
  if (tty) rl._writeToOutput = () => {
1026
1064
  };
1027
1065
  return {
1028
- ask: (query) => new Promise((resolve11) => {
1066
+ ask: (query) => new Promise((resolve10) => {
1029
1067
  process.stdout.write(query);
1030
1068
  rl.question("", (val) => {
1031
1069
  process.stdout.write("\n");
1032
- resolve11(val.trim());
1070
+ resolve10(val.trim());
1033
1071
  });
1034
1072
  }),
1035
1073
  close: () => rl.close()
@@ -1139,200 +1177,14 @@ async function secretsCommand(args) {
1139
1177
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
1140
1178
  const ociConfig2 = flag(args, "--oci-config");
1141
1179
  const ociKey = flag(args, "--oci-key");
1142
- const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
1180
+ const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
1143
1181
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
1144
1182
  return;
1145
1183
  }
1146
- if (sub !== "sync") {
1147
- console.log(
1148
- "usage:\n greenlight secrets sync [--repo owner/repo] [--env <env>] # push .greenlight/secrets.env\n greenlight secrets gather <name> [--repo owner/repo] [--env <env>] # guided, link-first, straight to GitHub (no disk/logs)\n [--oci-config <path>] [--oci-key <path>] # auto-fill OCI auth from the API-key config preview + .pem"
1149
- );
1150
- process.exit(sub ? 1 : 0);
1151
- }
1152
- const { count } = syncSecrets({
1153
- cwd: process.cwd(),
1154
- repo: flag(args, "--repo"),
1155
- env: flag(args, "--env")
1156
- });
1157
- if (count === 0) {
1158
- console.log("no secrets to sync");
1159
- return;
1160
- }
1161
- console.log(
1162
- `
1163
- ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supported.)`
1164
- );
1165
- }
1166
-
1167
- // src/tokens.ts
1168
- var SECRETS_DIR = ".greenlight";
1169
- var SECRETS_FILE = "secrets.env";
1170
- function presentEnv(cwd) {
1171
- const out = {};
1172
- const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
1173
- if (existsSync4(p)) {
1174
- for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
1175
- }
1176
- for (const [k, v] of Object.entries(process.env)) {
1177
- if (v !== void 0 && !(k in out)) out[k] = v;
1178
- }
1179
- return out;
1180
- }
1181
- function upsertSecret(cwd, key, value) {
1182
- const dir = resolve4(cwd, SECRETS_DIR);
1183
- mkdirSync(dir, { recursive: true });
1184
- const p = resolve4(dir, SECRETS_FILE);
1185
- const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
1186
- const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
1187
- if (idx >= 0) lines[idx] = `${key}=${value}`;
1188
- else {
1189
- while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
1190
- lines.push(`${key}=${value}`);
1191
- }
1192
- writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
1193
- `, { mode: 384 });
1194
- }
1195
- async function ensureTokensForTool(cwd, tool, opts = {}) {
1196
- const doVerify = opts.verify !== false;
1197
- const interactive = Boolean(process.stdin.isTTY);
1198
- const env = presentEnv(cwd);
1199
- const results = [];
1200
- const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
1201
- try {
1202
- for (const spec of tokensForTool(tool)) {
1203
- let value = env[spec.envVar];
1204
- if (value) {
1205
- results.push({ envVar: spec.envVar, outcome: "present" });
1206
- } else if (rl) {
1207
- console.log(`
1208
- ${spec.envVar} \u2014 ${spec.label}`);
1209
- if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
1210
- const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
1211
- if (!entered) {
1212
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1213
- continue;
1214
- }
1215
- upsertSecret(cwd, spec.envVar, entered);
1216
- env[spec.envVar] = entered;
1217
- value = entered;
1218
- results.push({ envVar: spec.envVar, outcome: "entered" });
1219
- } else {
1220
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1221
- continue;
1222
- }
1223
- if (value && doVerify && spec.verify) {
1224
- let check;
1225
- try {
1226
- check = await spec.verify(value, env);
1227
- } catch (e) {
1228
- check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
1229
- }
1230
- const last = results[results.length - 1];
1231
- if (last) last.verify = check;
1232
- if (!check.ok && !spec.optional) {
1233
- throw new Error(
1234
- `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
1235
- );
1236
- }
1237
- }
1238
- }
1239
- } finally {
1240
- rl?.close();
1241
- }
1242
- return results;
1243
- }
1244
-
1245
- // src/commands/agent.ts
1246
- import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1247
- import { resolve as resolve5 } from "path";
1248
-
1249
- // src/agent-kit.ts
1250
- function recommendedMcp(tool) {
1251
- return mcpForTool(tool);
1252
- }
1253
- function mergeMcpServers(existing, add) {
1254
- const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
1255
- for (const [name, val] of Object.entries(add)) {
1256
- if (out.mcpServers[name]) continue;
1257
- out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
1258
- }
1259
- return out;
1260
- }
1261
-
1262
- // src/commands/agent.ts
1263
- var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
1264
-
1265
- This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
1266
- tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
1267
- branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
1268
- push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
1269
- Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
1270
- pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
1271
-
1272
- Agentic kit:
1273
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
1274
- - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
1275
- Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
1276
- - Best-practice skills (one-time, user scope):
1277
- \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
1278
- `;
1279
- function materializeAgentKit(dir, tool) {
1280
- const src = skillAssetDir();
1281
- if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
1282
- const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
1283
- mkdirSync2(dest, { recursive: true });
1284
- cpSync(src, dest, { recursive: true });
1285
- console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
1286
- for (const pack of packsForTool(tool)) {
1287
- if (!pack.skill) continue;
1288
- const skillSrc = skillAssetDir(pack.skill);
1289
- if (!existsSync5(skillSrc)) continue;
1290
- const skillDest = resolve5(dir, ".claude/skills", pack.skill);
1291
- mkdirSync2(skillDest, { recursive: true });
1292
- cpSync(skillSrc, skillDest, { recursive: true });
1293
- console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
1294
- }
1295
- const mcpPath = resolve5(dir, ".mcp.json");
1296
- const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
1297
- const servers = recommendedMcp(tool);
1298
- writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
1299
- `);
1300
- console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
1301
- const claudePath = resolve5(dir, "CLAUDE.md");
1302
- const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
1303
- const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
1304
- if (existing.includes(marker)) {
1305
- console.log("\xB7 CLAUDE.md already has the loop block");
1306
- } else {
1307
- writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
1308
-
1309
- ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
1310
- console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
1311
- }
1312
- }
1313
- async function agentCommand(args) {
1314
- if (args[0] !== "sync") {
1315
- console.log(
1316
- "usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
1317
- );
1318
- process.exit(args[0] ? 1 : 0);
1319
- }
1320
- const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
1321
- if (name) {
1322
- const { config } = await loadManifest();
1323
- const entry = resolveEntry(config, name);
1324
- const dir = resolve5(process.cwd(), entry.dir ?? ".");
1325
- materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
1326
- console.log(
1327
- `
1328
- Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
1329
- );
1330
- return;
1331
- }
1332
- materializeAgentKit(process.cwd());
1333
1184
  console.log(
1334
- "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1185
+ "usage:\n greenlight secrets gather <name> [--repo owner/repo] [--env <env>] # guided, link-first, straight to GitHub (no disk/logs)\n [--oci-config <path>] [--oci-key <path>] # auto-fill OCI auth from the API-key config preview + .pem"
1335
1186
  );
1187
+ process.exit(sub ? 1 : 0);
1336
1188
  }
1337
1189
 
1338
1190
  // src/commands/add.ts
@@ -1346,25 +1198,25 @@ function templateDir(lane, target) {
1346
1198
  }
1347
1199
  function registerWorkspaceMember(cwd, member) {
1348
1200
  const wsPath = join(cwd, "pnpm-workspace.yaml");
1349
- if (!existsSync6(wsPath)) {
1350
- writeFileSync3(wsPath, `packages:
1201
+ if (!existsSync5(wsPath)) {
1202
+ writeFileSync2(wsPath, `packages:
1351
1203
  - "${member}"
1352
1204
  `);
1353
1205
  console.log(`\u2714 created pnpm-workspace.yaml (member ${member})`);
1354
1206
  return;
1355
1207
  }
1356
- const text = readFileSync4(wsPath, "utf8");
1208
+ const text = readFileSync3(wsPath, "utf8");
1357
1209
  if (text.includes(member) || /^\s*-\s*["']?tools\/\*/m.test(text)) return;
1358
1210
  const lines = text.split("\n");
1359
1211
  const pkgIdx = lines.findIndex((l) => /^packages\s*:/.test(l));
1360
1212
  if (pkgIdx === -1) {
1361
- writeFileSync3(wsPath, `${text.replace(/\s*$/, "")}
1213
+ writeFileSync2(wsPath, `${text.replace(/\s*$/, "")}
1362
1214
  packages:
1363
1215
  - "${member}"
1364
1216
  `);
1365
1217
  } else {
1366
1218
  lines.splice(pkgIdx + 1, 0, ` - "${member}"`);
1367
- writeFileSync3(wsPath, lines.join("\n"));
1219
+ writeFileSync2(wsPath, lines.join("\n"));
1368
1220
  }
1369
1221
  console.log(`\u2714 registered ${member} in pnpm-workspace.yaml`);
1370
1222
  }
@@ -1398,60 +1250,48 @@ async function addCommand(args) {
1398
1250
  const data = entry?.data ?? "none";
1399
1251
  const envs = entry?.envs ?? ["beta", "prod"];
1400
1252
  const toolInfo = { lane, target, data };
1401
- const dest = resolve6(process.cwd(), "tools", name);
1402
- if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
1253
+ const dest = resolve5(process.cwd(), "tools", name);
1254
+ if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
1403
1255
  const src = templateDir(lane, target);
1404
- if (existsSync6(src)) {
1256
+ if (existsSync5(src)) {
1405
1257
  cpSync2(src, dest, { recursive: true });
1406
1258
  const pkgPath = join(dest, "package.json");
1407
- if (existsSync6(pkgPath)) {
1408
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1259
+ if (existsSync5(pkgPath)) {
1260
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1409
1261
  pkg.name = name;
1410
- writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1262
+ writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1411
1263
  `);
1412
1264
  }
1413
1265
  const shippedGitignore = join(dest, "gitignore");
1414
- if (existsSync6(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1266
+ if (existsSync5(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1415
1267
  const wranglerPath = join(dest, "wrangler.toml");
1416
- if (existsSync6(wranglerPath)) {
1417
- let wt = readFileSync4(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1418
- if (wt.includes("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID")) {
1419
- const token = presentEnv(process.cwd()).CLOUDFLARE_API_TOKEN;
1420
- const acct = token ? await resolveCloudflareAccountId(config.domain, token) : null;
1421
- if (acct) {
1422
- wt = wt.replaceAll("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID", acct);
1423
- console.log("\u2714 resolved the Cloudflare account id into wrangler.toml");
1424
- } else {
1425
- console.log(
1426
- "\xB7 could not resolve the Cloudflare account id \u2014 set account_id in wrangler.toml"
1427
- );
1428
- }
1429
- }
1430
- writeFileSync3(wranglerPath, wt);
1268
+ if (existsSync5(wranglerPath)) {
1269
+ const wt = readFileSync3(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1270
+ writeFileSync2(wranglerPath, wt);
1431
1271
  }
1432
1272
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1433
- if (existsSync6(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1273
+ if (existsSync5(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1434
1274
  } else {
1435
1275
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1436
1276
  }
1437
- writeFileSync3(path, serializeConfig(next));
1277
+ writeFileSync2(path, serializeConfig(next));
1438
1278
  console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
1439
1279
  const cwd = process.cwd();
1440
1280
  const providers = providersForTool(toolInfo);
1441
- const infraDir = resolve6(cwd, "infra");
1281
+ const infraDir = resolve5(cwd, "infra");
1442
1282
  const mainTf = join(infraDir, "main.tf");
1443
- if (!existsSync6(mainTf)) {
1444
- mkdirSync3(infraDir, { recursive: true });
1445
- writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1283
+ if (!existsSync5(mainTf)) {
1284
+ mkdirSync2(infraDir, { recursive: true });
1285
+ writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1446
1286
  console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
1447
1287
  } else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
1448
1288
  console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
1449
1289
  }
1450
1290
  const toolTf = join(infraDir, `${name}.tf`);
1451
- if (existsSync6(toolTf)) {
1291
+ if (existsSync5(toolTf)) {
1452
1292
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1453
1293
  } else {
1454
- writeFileSync3(
1294
+ writeFileSync2(
1455
1295
  toolTf,
1456
1296
  emitToolTf({
1457
1297
  name,
@@ -1468,13 +1308,13 @@ async function addCommand(args) {
1468
1308
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1469
1309
  }
1470
1310
  if (lane === "agent") {
1471
- const wfDir = resolve6(cwd, ".github/workflows");
1311
+ const wfDir = resolve5(cwd, ".github/workflows");
1472
1312
  const wfPath = join(wfDir, `deploy-${name}.yml`);
1473
- if (existsSync6(wfPath)) {
1313
+ if (existsSync5(wfPath)) {
1474
1314
  console.log(`\xB7 .github/workflows/deploy-${name}.yml exists \u2014 left as-is`);
1475
1315
  } else {
1476
- mkdirSync3(wfDir, { recursive: true });
1477
- writeFileSync3(wfPath, emitAgentDeployWorkflow(name, config.domain));
1316
+ mkdirSync2(wfDir, { recursive: true });
1317
+ writeFileSync2(wfPath, emitAgentDeployWorkflow(name, config.domain));
1478
1318
  console.log(`\u2714 wrote .github/workflows/deploy-${name}.yml`);
1479
1319
  }
1480
1320
  }
@@ -1500,8 +1340,8 @@ Next:${gather ? "" : `
1500
1340
 
1501
1341
  // src/commands/adopt.ts
1502
1342
  import { execFileSync as execFileSync2 } from "child_process";
1503
- import { cpSync as cpSync3, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync4 } from "fs";
1504
- import { join as join2, resolve as resolve7 } from "path";
1343
+ import { cpSync as cpSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
1344
+ import { join as join2, resolve as resolve6 } from "path";
1505
1345
  var REF = MODULE_REF;
1506
1346
  function flag3(args, name) {
1507
1347
  const i = args.indexOf(name);
@@ -1517,7 +1357,7 @@ function mergePackageJson(existing, repoName, vendor) {
1517
1357
  }
1518
1358
  function vendorDeps(vendorDir) {
1519
1359
  const out = {};
1520
- if (!existsSync7(vendorDir)) return out;
1360
+ if (!existsSync6(vendorDir)) return out;
1521
1361
  for (const f of readdirSync(vendorDir)) {
1522
1362
  if (!f.endsWith(".tgz")) continue;
1523
1363
  const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
@@ -1972,12 +1812,12 @@ export default [api, ...agentWeb];
1972
1812
  `;
1973
1813
  }
1974
1814
  function writeIfAbsent(path, contents, label) {
1975
- if (existsSync7(path)) {
1815
+ if (existsSync6(path)) {
1976
1816
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
1977
1817
  return;
1978
1818
  }
1979
- mkdirSync4(resolve7(path, ".."), { recursive: true });
1980
- writeFileSync4(path, contents);
1819
+ mkdirSync3(resolve6(path, ".."), { recursive: true });
1820
+ writeFileSync3(path, contents);
1981
1821
  console.log(`\u2714 ${label}`);
1982
1822
  }
1983
1823
  async function adoptCommand(args) {
@@ -2013,10 +1853,10 @@ async function adoptWrapper(ctx) {
2013
1853
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
2014
1854
  const cwd = process.cwd();
2015
1855
  const toolRel = `tools/${name}`;
2016
- const dest = resolve7(cwd, toolRel);
1856
+ const dest = resolve6(cwd, toolRel);
2017
1857
  console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
2018
1858
  `);
2019
- if (!existsSync7(dest)) {
1859
+ if (!existsSync6(dest)) {
2020
1860
  try {
2021
1861
  execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
2022
1862
  console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
@@ -2052,7 +1892,7 @@ async function adoptWrapper(ctx) {
2052
1892
  }
2053
1893
  } : {}
2054
1894
  });
2055
- writeFileSync4(regPath, serializeConfig(nextReg));
1895
+ writeFileSync3(regPath, serializeConfig(nextReg));
2056
1896
  console.log(
2057
1897
  `\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
2058
1898
  );
@@ -2070,7 +1910,7 @@ async function adoptWrapper(ctx) {
2070
1910
  );
2071
1911
  }
2072
1912
  const providers = providersForTool({ lane, target, data });
2073
- if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1913
+ if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
2074
1914
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
2075
1915
  }
2076
1916
  materializeAgentKit(dest, { lane, target, data });
@@ -2122,9 +1962,9 @@ Next:
2122
1962
  }
2123
1963
  async function adoptStandalone(ctx) {
2124
1964
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
2125
- const repo = resolve7(process.cwd(), repoArg);
2126
- if (!existsSync7(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
2127
- const regVendor = resolve7(process.cwd(), "vendor");
1965
+ const repo = resolve6(process.cwd(), repoArg);
1966
+ if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1967
+ const regVendor = resolve6(process.cwd(), "vendor");
2128
1968
  const vendor = vendorDeps(regVendor);
2129
1969
  if (Object.keys(vendor).length === 0) {
2130
1970
  throw new Error(
@@ -2142,15 +1982,15 @@ async function adoptStandalone(ctx) {
2142
1982
  );
2143
1983
  const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
2144
1984
  const pkgPath = join2(repo, "package.json");
2145
- const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
2146
- writeFileSync4(
1985
+ const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
1986
+ writeFileSync3(
2147
1987
  pkgPath,
2148
1988
  `${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
2149
1989
  `
2150
1990
  );
2151
1991
  console.log("\u2714 package.json (merged framework deps + overrides)");
2152
1992
  const repoVendor = join2(repo, "vendor");
2153
- mkdirSync4(repoVendor, { recursive: true });
1993
+ mkdirSync3(repoVendor, { recursive: true });
2154
1994
  for (const f of readdirSync(regVendor)) {
2155
1995
  if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
2156
1996
  }
@@ -2184,7 +2024,7 @@ async function adoptStandalone(ctx) {
2184
2024
  external: true,
2185
2025
  adopted: true
2186
2026
  });
2187
- writeFileSync4(regPath, serializeConfig(nextReg));
2027
+ writeFileSync3(regPath, serializeConfig(nextReg));
2188
2028
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
2189
2029
  console.log(`
2190
2030
  Next (in the adopted repo):
@@ -2197,20 +2037,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
2197
2037
  }
2198
2038
  function addGreenlightScript(dir) {
2199
2039
  const pkgPath = join2(dir, "package.json");
2200
- if (!existsSync7(pkgPath)) {
2040
+ if (!existsSync6(pkgPath)) {
2201
2041
  console.log(
2202
2042
  "\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
2203
2043
  );
2204
2044
  return;
2205
2045
  }
2206
- const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
2046
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2207
2047
  pkg.scripts = { ...pkg.scripts ?? {} };
2208
2048
  if (pkg.scripts.greenlight) {
2209
2049
  console.log("\xB7 package.json already has a greenlight script");
2210
2050
  return;
2211
2051
  }
2212
2052
  pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
2213
- writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
2053
+ writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
2214
2054
  `);
2215
2055
  console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
2216
2056
  }
@@ -2355,10 +2195,10 @@ async function deployCommand(args) {
2355
2195
  // src/commands/doctor.ts
2356
2196
  import { execFileSync as execFileSync4 } from "child_process";
2357
2197
  import { lookup } from "dns/promises";
2358
- import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
2198
+ import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
2359
2199
  import { join as join4 } from "path";
2360
2200
  function dirCheck(label, dir) {
2361
- return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2201
+ return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2362
2202
  }
2363
2203
  function conformanceChecks(t, root) {
2364
2204
  const out = [];
@@ -2368,7 +2208,7 @@ function conformanceChecks(t, root) {
2368
2208
  join4(toolDir, `verify/${t.name}.config.ts`),
2369
2209
  join4(toolDir, "verify.config.ts")
2370
2210
  ] : [join4(toolDir, "verify.config.ts")];
2371
- const found = specCandidates.find((p) => existsSync8(join4(root, p)));
2211
+ const found = specCandidates.find((p) => existsSync7(join4(root, p)));
2372
2212
  out.push({
2373
2213
  name: `${t.name}: in the verify loop`,
2374
2214
  status: found ? "ok" : "warn",
@@ -2394,14 +2234,14 @@ function conformanceChecks(t, root) {
2394
2234
  }
2395
2235
  if (!t.external && t.lane === "next" && t.target === "vercel") {
2396
2236
  const wsPath = join4(root, "pnpm-workspace.yaml");
2397
- const ws = existsSync8(wsPath) ? readFileSync6(wsPath, "utf8") : "";
2237
+ const ws = existsSync7(wsPath) ? readFileSync5(wsPath, "utf8") : "";
2398
2238
  const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
2399
2239
  out.push({
2400
2240
  name: `${t.name}: pnpm workspace member`,
2401
2241
  status: member ? "ok" : "warn",
2402
2242
  detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
2403
2243
  });
2404
- const hasVercelJson = existsSync8(join4(root, toolDir, "vercel.json"));
2244
+ const hasVercelJson = existsSync7(join4(root, toolDir, "vercel.json"));
2405
2245
  out.push({
2406
2246
  name: `${t.name}: vercel.json framework`,
2407
2247
  status: hasVercelJson ? "ok" : "warn",
@@ -2415,7 +2255,7 @@ function versionDriftCheck(root) {
2415
2255
  let installed;
2416
2256
  try {
2417
2257
  const pkg = JSON.parse(
2418
- readFileSync6(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2258
+ readFileSync5(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2419
2259
  );
2420
2260
  installed = pkg.version;
2421
2261
  } catch {
@@ -2423,7 +2263,7 @@ function versionDriftCheck(root) {
2423
2263
  const refs = /* @__PURE__ */ new Set();
2424
2264
  try {
2425
2265
  for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
2426
- const body = readFileSync6(join4(root, "infra", f), "utf8");
2266
+ const body = readFileSync5(join4(root, "infra", f), "utf8");
2427
2267
  for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
2428
2268
  if (m[1]) refs.add(m[1]);
2429
2269
  }
@@ -2479,7 +2319,7 @@ function runDoctor(config, root) {
2479
2319
  checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
2480
2320
  if (t.dir) {
2481
2321
  checks.push(
2482
- existsSync8(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2322
+ existsSync7(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2483
2323
  name: `${t.name}: dir present`,
2484
2324
  status: "warn",
2485
2325
  detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
@@ -2570,9 +2410,73 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2570
2410
  }
2571
2411
 
2572
2412
  // src/commands/init.ts
2573
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2574
- import { resolve as resolve8 } from "path";
2575
- import { createInterface as createInterface3 } from "readline/promises";
2413
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2414
+ import { resolve as resolve7 } from "path";
2415
+ import { createInterface as createInterface2 } from "readline/promises";
2416
+
2417
+ // src/tokens.ts
2418
+ function presentEnv() {
2419
+ const out = {};
2420
+ for (const [k, v] of Object.entries(process.env)) {
2421
+ if (v !== void 0) out[k] = v;
2422
+ }
2423
+ return out;
2424
+ }
2425
+ async function ensureTokensForTool(repo, tool, opts = {}) {
2426
+ const doVerify = opts.verify !== false;
2427
+ const env = presentEnv();
2428
+ const already = listGitHubSecrets(repo, opts.env);
2429
+ const results = [];
2430
+ const prompt = process.stdin.isTTY ? hiddenPrompter() : null;
2431
+ try {
2432
+ for (const spec of tokensForTool(tool)) {
2433
+ const key = secretKeyFor(spec, "", void 0);
2434
+ if (key === "GITHUB_TOKEN") {
2435
+ results.push({ envVar: spec.envVar, outcome: "skipped" });
2436
+ continue;
2437
+ }
2438
+ if (env[spec.envVar] || already?.has(key)) {
2439
+ results.push({ envVar: spec.envVar, outcome: "present" });
2440
+ continue;
2441
+ }
2442
+ if (!prompt) {
2443
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2444
+ continue;
2445
+ }
2446
+ console.log(`
2447
+ ${key} \u2014 ${spec.label}`);
2448
+ if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
2449
+ const entered = await prompt.ask(
2450
+ ` value${spec.optional ? " (optional, Enter to skip)" : ""}: `
2451
+ );
2452
+ if (!entered) {
2453
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2454
+ continue;
2455
+ }
2456
+ env[spec.envVar] = entered;
2457
+ let check;
2458
+ if (doVerify && spec.verify) {
2459
+ try {
2460
+ check = await spec.verify(entered, env);
2461
+ } catch (e) {
2462
+ check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
2463
+ }
2464
+ if (!check.ok && !spec.optional) {
2465
+ throw new Error(
2466
+ `${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
2467
+ );
2468
+ }
2469
+ }
2470
+ setGitHubSecret(repo, opts.env, key, entered);
2471
+ results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
2472
+ }
2473
+ } finally {
2474
+ prompt?.close();
2475
+ }
2476
+ return results;
2477
+ }
2478
+
2479
+ // src/commands/init.ts
2576
2480
  function flag5(args, name) {
2577
2481
  const i = args.indexOf(name);
2578
2482
  return i >= 0 ? args[i + 1] : void 0;
@@ -2594,7 +2498,7 @@ function wrapperPackageJson(name) {
2594
2498
  }
2595
2499
  var WRAPPER_GITIGNORE = `# Greenlight wrapper
2596
2500
  node_modules/
2597
- .greenlight/ # gathered tokens \u2014 never committed
2501
+ .greenlight/ # local scratch \u2014 never committed (tokens live in GitHub Actions)
2598
2502
  .terraform/
2599
2503
  *.tfplan
2600
2504
  tf.plan
@@ -2656,12 +2560,12 @@ jobs:
2656
2560
  `;
2657
2561
  }
2658
2562
  function scaffoldIfAbsent(path, contents, label) {
2659
- if (existsSync9(path)) {
2563
+ if (existsSync8(path)) {
2660
2564
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
2661
2565
  return;
2662
2566
  }
2663
- mkdirSync5(resolve8(path, ".."), { recursive: true });
2664
- writeFileSync5(path, contents);
2567
+ mkdirSync4(resolve7(path, ".."), { recursive: true });
2568
+ writeFileSync4(path, contents);
2665
2569
  console.log(`\u2714 wrote ${label}`);
2666
2570
  }
2667
2571
  var TOKEN_FLAGS = {
@@ -2676,70 +2580,74 @@ async function initCommand(args) {
2676
2580
  let domain = flag5(args, "--domain");
2677
2581
  if (!domain) {
2678
2582
  if (!process.stdin.isTTY) throw new Error("init needs --domain <domain> (no TTY for prompts)");
2679
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
2583
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
2680
2584
  domain = (await rl.question("Domain (e.g. example.dev): ")).trim();
2681
2585
  rl.close();
2682
2586
  }
2683
2587
  if (!domain) throw new Error("a domain is required");
2684
2588
  const cwd = process.cwd();
2685
- const configPath = resolve8(cwd, "greenlight.config.ts");
2686
- if (existsSync9(configPath) && !force) {
2589
+ const configPath = resolve7(cwd, "greenlight.config.ts");
2590
+ if (existsSync8(configPath) && !force) {
2687
2591
  throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
2688
2592
  }
2689
- writeFileSync5(configPath, scaffoldConfig(domain));
2593
+ writeFileSync4(configPath, scaffoldConfig(domain));
2690
2594
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2691
2595
  const repoName = domain.replace(/\./g, "-");
2692
2596
  scaffoldIfAbsent(
2693
- resolve8(cwd, ".github/workflows/infra.yml"),
2597
+ resolve7(cwd, ".github/workflows/infra.yml"),
2694
2598
  wrapperInfraYml(),
2695
2599
  ".github/workflows/infra.yml (HCP-backed terraform apply on push)"
2696
2600
  );
2697
- scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2698
- scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2699
- scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2700
- scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
2701
- const secrets = [];
2702
- for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
2703
- const v = flag5(args, f);
2704
- if (v) secrets.push(`${key}=${v}`);
2705
- }
2706
- if (secrets.length > 0) {
2707
- mkdirSync5(resolve8(cwd, ".greenlight"), { recursive: true });
2708
- writeFileSync5(resolve8(cwd, ".greenlight/secrets.env"), `${secrets.join("\n")}
2709
- `, {
2710
- mode: 384
2711
- });
2712
- console.log(`\u2714 wrote .greenlight/secrets.env (${secrets.length} token(s), gitignored)`);
2713
- }
2714
- if (process.stdin.isTTY && !args.includes("--no-tokens")) {
2715
- try {
2716
- await ensureTokensForTool(cwd, {}, { verify: !args.includes("--no-verify") });
2717
- } catch (e) {
2718
- console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
2601
+ scaffoldIfAbsent(resolve7(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2602
+ scaffoldIfAbsent(resolve7(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2603
+ scaffoldIfAbsent(resolve7(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2604
+ scaffoldIfAbsent(resolve7(cwd, ".node-version"), "24\n", ".node-version");
2605
+ const repo = flag5(args, "--repo") ?? detectRepo(cwd);
2606
+ let pushed = 0;
2607
+ if (repo && !args.includes("--no-push")) {
2608
+ for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
2609
+ const v = flag5(args, f);
2610
+ if (!v || key.startsWith("GITHUB_")) continue;
2611
+ try {
2612
+ setGitHubSecret(repo, void 0, key, v);
2613
+ console.log(`\u2714 set ${key} \u2192 ${repo} (GitHub Actions)`);
2614
+ pushed++;
2615
+ } catch (e) {
2616
+ console.log(`! could not set ${key}: ${e instanceof Error ? e.message : String(e)}`);
2617
+ }
2719
2618
  }
2720
2619
  }
2721
- let pushed = false;
2722
- if (existsSync9(resolve8(cwd, ".greenlight/secrets.env")) && !args.includes("--no-push")) {
2723
- try {
2724
- const { repo, count } = syncSecrets({ cwd, repo: flag5(args, "--repo") });
2725
- console.log(`\u2714 pushed ${count} secret(s) to ${repo} (GitHub Actions)`);
2726
- pushed = true;
2727
- } catch (e) {
2728
- console.log(`! skipped pushing secrets: ${e instanceof Error ? e.message : String(e)}`);
2729
- console.log(" run `greenlight secrets sync` once `gh` is authenticated.");
2620
+ if (process.stdin.isTTY && !args.includes("--no-tokens")) {
2621
+ if (repo) {
2622
+ try {
2623
+ const results = await ensureTokensForTool(
2624
+ repo,
2625
+ {},
2626
+ {
2627
+ verify: !args.includes("--no-verify")
2628
+ }
2629
+ );
2630
+ pushed += results.filter((r) => r.outcome === "entered").length;
2631
+ } catch (e) {
2632
+ console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
2633
+ }
2634
+ } else {
2635
+ console.log(
2636
+ "\n\xB7 no GitHub repo detected yet \u2014 create it + `gh auth login`, then set the base secrets\n (CLOUDFLARE_API_TOKEN, TF_API_TOKEN) via `greenlight add <tool>` (prompts them) or `gh secret set`."
2637
+ );
2730
2638
  }
2731
2639
  }
2732
2640
  console.log(`
2733
2641
  Next:
2734
2642
  1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
2735
- # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
2643
+ # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (it also prompts the base tokens if they are not set yet)"}
2736
2644
  2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state.md
2737
2645
  3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
2738
2646
  4. greenlight verify <name> --env prod | greenlight doctor`);
2739
2647
  }
2740
2648
 
2741
2649
  // src/commands/migrations.ts
2742
- import { existsSync as existsSync10, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2650
+ import { existsSync as existsSync9, readFileSync as readFileSync6, readdirSync as readdirSync3 } from "fs";
2743
2651
  import { join as join5 } from "path";
2744
2652
  var DEFAULT_DIR = "supabase/migrations";
2745
2653
  var CANDIDATE_DIRS = [
@@ -2751,7 +2659,7 @@ var CANDIDATE_DIRS = [
2751
2659
  ];
2752
2660
  function resolveMigrationsDir(explicit, root = process.cwd()) {
2753
2661
  if (explicit) return explicit;
2754
- return CANDIDATE_DIRS.find((d) => existsSync10(join5(root, d))) ?? DEFAULT_DIR;
2662
+ return CANDIDATE_DIRS.find((d) => existsSync9(join5(root, d))) ?? DEFAULT_DIR;
2755
2663
  }
2756
2664
  async function migrationsCommand(args) {
2757
2665
  if (args[0] !== "scan") {
@@ -2777,7 +2685,7 @@ async function migrationsCommand(args) {
2777
2685
  }
2778
2686
  const files = names.map((f) => ({
2779
2687
  path: join5(dir, f),
2780
- content: readFileSync7(join5(dir, f), "utf8")
2688
+ content: readFileSync6(join5(dir, f), "utf8")
2781
2689
  }));
2782
2690
  const findings = scanSqlFiles(files);
2783
2691
  if (findings.length === 0) {
@@ -2802,12 +2710,12 @@ ${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn).
2802
2710
 
2803
2711
  // src/commands/preview.ts
2804
2712
  import { execFileSync as execFileSync5, spawn } from "child_process";
2805
- import { resolve as resolve10 } from "path";
2713
+ import { resolve as resolve9 } from "path";
2806
2714
  import { setTimeout as sleep } from "timers/promises";
2807
2715
 
2808
2716
  // src/commands/verify.ts
2809
2717
  import { spawnSync } from "child_process";
2810
- import { resolve as resolve9 } from "path";
2718
+ import { resolve as resolve8 } from "path";
2811
2719
  function defaultSpec(lane) {
2812
2720
  switch (lane) {
2813
2721
  case "astro":
@@ -2935,7 +2843,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
2935
2843
  if (reachableTimeoutMs > 0) {
2936
2844
  console.log(`waiting up to ${reachableTimeoutMs / 1e3}s for ${url} to become reachable\u2026`);
2937
2845
  }
2938
- const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2846
+ const toolDir = resolve8(process.cwd(), entry.dir ?? ".");
2939
2847
  const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
2940
2848
  attachFailureLogs(reports, specs, toolDir);
2941
2849
  for (const report of reports) printReport(report);
@@ -2979,7 +2887,7 @@ async function verifyLocal(entry, url) {
2979
2887
  process.env.GREENLIGHT_PREVIEW = "1";
2980
2888
  process.env.GREENLIGHT_VERIFY_URL = url;
2981
2889
  const specs = await loadSpecs(entry);
2982
- const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2890
+ const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2983
2891
  const reports = await verifyAll(url, specs, { toolDir });
2984
2892
  for (const report of reports) printReport(report);
2985
2893
  return allPass(reports);
@@ -2990,7 +2898,7 @@ async function previewViaDescriptor(entry, name, portOverride) {
2990
2898
  const port = portOverride ?? pv.port ?? entry.port ?? lane.port;
2991
2899
  const path = pv.path ?? lane.path;
2992
2900
  const url = `http://localhost:${port}${path}`;
2993
- const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2901
+ const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2994
2902
  console.log(`preview ${name}: ${pv.command} (\u2192 ${url})`);
2995
2903
  const child = spawn(pv.command, {
2996
2904
  cwd: toolDir,
@@ -3303,7 +3211,6 @@ var HELP = `greenlight <command>
3303
3211
  promote <name> [--perform] [--push] gated develop -> main fast-forward
3304
3212
  status <name> last ship/deploy/verify run for a tool (via gh)
3305
3213
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
3306
- secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
3307
3214
  agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
3308
3215
  adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
3309
3216
  migrations scan [<dir>] [--strict] dangerous-SQL gate for migrations (pre-apply)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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.3.1",
35
- "@rtrentjones/greenlight-loop": "0.3.1",
36
- "@rtrentjones/greenlight-shared": "0.3.1",
37
- "@rtrentjones/greenlight-verify": "0.3.1"
34
+ "@rtrentjones/greenlight-adapters": "0.4.1",
35
+ "@rtrentjones/greenlight-verify": "0.4.1",
36
+ "@rtrentjones/greenlight-loop": "0.4.1",
37
+ "@rtrentjones/greenlight-shared": "0.4.1"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",
@@ -1,10 +1,10 @@
1
- # Agent Worker. `greenlight add` rewrites `name` + the route domain + the account_id, and emits a
2
- # .github/workflows/deploy-<name>.yml that (on push to main) creates the KV namespace, deploys, sets
3
- # the GEMINI_API_KEY + RUN_TOKEN Worker secrets from GitHub secrets, seeds, and verifies. So you only
4
- # add those two GitHub secrets — no manual wrangler.
1
+ # Agent Worker. `greenlight add` rewrites `name` + the route domain, and emits a
2
+ # .github/workflows/deploy-<name>.yml that (on push to main) resolves the account id + KV namespace,
3
+ # deploys, sets the GEMINI_API_KEY + RUN_TOKEN Worker secrets from GitHub secrets, seeds, and verifies.
4
+ # So you only add those two GitHub secrets — no manual wrangler, no local secrets.
5
5
  name = "agent-tool"
6
- # Non-secret account id (committed config). `greenlight add` resolves + fills this from your domain's
7
- # zone; without it wrangler calls /memberships, which a scoped API token can't do.
6
+ # Non-secret account id. The emitted deploy workflow resolves it from your domain's zone in CI and
7
+ # fills this placeholder (wrangler can't call /memberships to auto-discover it with a scoped token).
8
8
  account_id = "REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID"
9
9
  main = "src/index.ts"
10
10
  compatibility_date = "2025-06-01"