@rtrentjones/greenlight 0.3.0 → 0.3.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.
@@ -29,9 +29,17 @@ POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:ge
29
29
  → candidates[0].content.parts[0].text
30
30
  ```
31
31
 
32
- ## Deploy — wrangler (workers target)
32
+ ## Deploy — emitted CI (push to main)
33
33
 
34
- Like the astro blog: cron + KV + secret + `custom_domain` in `wrangler.toml`; no Terraform.
34
+ `greenlight add` resolves the Cloudflare **account id** into `wrangler.toml` (so wrangler skips the
35
+ `/memberships` call a scoped token can't do) and emits **`.github/workflows/deploy-<name>.yml`**. On
36
+ a push to main that touches `tools/<name>`, that workflow: **creates the KV namespace** (find-or-create
37
+ in-CI — no manual step, the id stays a placeholder), deploys the Worker (cron + `custom_domain` from
38
+ `wrangler.toml`), sets the `GEMINI_API_KEY` + `RUN_TOKEN` **Worker secrets** from GitHub secrets, seeds
39
+ the first run, and verifies. So the only setup is **adding those two GitHub secrets**. (Local instead:
40
+ `pnpm exec wrangler deploy --env prod`.)
41
+
42
+ `wrangler.toml` carries the cron + KV binding + the per-env `custom_domain` route; no Terraform.
35
43
 
36
44
  ```toml
37
45
  [triggers]
package/dist/bin.js CHANGED
@@ -15,8 +15,97 @@ 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 existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
19
- import { join, resolve as resolve5 } from "path";
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";
20
+
21
+ // src/agent-deploy.ts
22
+ function emitAgentDeployWorkflow(name, domain) {
23
+ return `name: deploy-${name}
24
+
25
+ # Agent "${name}" \u2014 a cron-triggered Cloudflare Worker (Gemini-backed). Emitted by \`greenlight add\`.
26
+ # On a push to main touching tools/${name}, or manually: deploys the Worker, sets its secrets from
27
+ # GitHub secrets, seeds the first run, and verifies. Creds-guarded (skips if the secrets are absent).
28
+ on:
29
+ push:
30
+ branches: [main]
31
+ paths: ['tools/${name}/**']
32
+ workflow_dispatch:
33
+
34
+ permissions:
35
+ contents: read
36
+
37
+ concurrency:
38
+ group: deploy-${name}
39
+ cancel-in-progress: false
40
+
41
+ jobs:
42
+ deploy:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: jdx/mise-action@v2
47
+ - run: pnpm install --frozen-lockfile
48
+
49
+ - name: Check creds
50
+ id: creds
51
+ env:
52
+ CF: \${{ secrets.CLOUDFLARE_API_TOKEN }}
53
+ GK: \${{ secrets.GEMINI_API_KEY }}
54
+ run: |
55
+ if [ -n "$CF" ] && [ -n "$GK" ]; then echo "have=1" >> "$GITHUB_OUTPUT"; else echo "have=0" >> "$GITHUB_OUTPUT"; fi
56
+
57
+ - name: Deploy + Worker secrets + seed
58
+ if: steps.creds.outputs.have == '1'
59
+ env:
60
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
61
+ GEMINI_API_KEY: \${{ secrets.GEMINI_API_KEY }}
62
+ RUN_TOKEN: \${{ secrets.RUN_TOKEN }}
63
+ run: |
64
+ cd tools/${name}
65
+ # KV namespace as code: find-or-create the STATE namespace (idempotent), then inject its id
66
+ # into wrangler.toml for this deploy. The id is non-secret + derived, so the repo keeps the
67
+ # REPLACE_WITH_KV_NAMESPACE_ID placeholder \u2014 no manual create, no hardcoded id.
68
+ ID=$(pnpm exec wrangler kv namespace list 2>/dev/null | jq -r '.[] | select(.title | test("${name}.*STATE")) | .id' | head -1)
69
+ if [ -z "$ID" ] || [ "$ID" = "null" ]; then
70
+ pnpm exec wrangler kv namespace create STATE || true
71
+ ID=$(pnpm exec wrangler kv namespace list 2>/dev/null | jq -r '.[] | select(.title | test("${name}.*STATE")) | .id' | head -1)
72
+ fi
73
+ if [ -z "$ID" ] || [ "$ID" = "null" ]; then echo "::error::could not resolve the STATE KV namespace id (token needs Workers KV Storage:Edit?)"; exit 1; fi
74
+ sed -i "s/REPLACE_WITH_KV_NAMESPACE_ID/$ID/g" wrangler.toml
75
+ pnpm exec wrangler deploy --env prod
76
+ printf '%s' "$GEMINI_API_KEY" | pnpm exec wrangler secret put GEMINI_API_KEY --env prod
77
+ printf '%s' "$RUN_TOKEN" | pnpm exec wrangler secret put RUN_TOKEN --env prod
78
+ cd ../..
79
+ # Seed the first run (the cron is daily). Retry while the custom domain propagates.
80
+ for i in $(seq 1 8); do
81
+ if curl -fsS -XPOST "https://${name}.${domain}/run" -H "Authorization: Bearer $RUN_TOKEN" >/dev/null; then
82
+ echo "seeded"; break
83
+ fi
84
+ echo "seed attempt $i: not ready, retrying in 10s"; sleep 10
85
+ done
86
+
87
+ - name: Verify
88
+ if: steps.creds.outputs.have == '1'
89
+ run: pnpm exec greenlight verify ${name} --env prod
90
+
91
+ - name: Skip notice
92
+ if: steps.creds.outputs.have != '1'
93
+ run: echo "Missing CLOUDFLARE_API_TOKEN or GEMINI_API_KEY \u2014 ${name} deploy skipped."
94
+ `;
95
+ }
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
+ }
20
109
 
21
110
  // src/asset-paths.ts
22
111
  import { existsSync } from "fs";
@@ -501,7 +590,7 @@ function tokensForTool(tool) {
501
590
  }
502
591
 
503
592
  // src/version.ts
504
- var MODULE_REF = "v0.3.0";
593
+ var MODULE_REF = "v0.3.1";
505
594
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
506
595
  function moduleSource(module, ref = MODULE_REF) {
507
596
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -826,103 +915,15 @@ function providersForTool(tool) {
826
915
  return out;
827
916
  }
828
917
 
829
- // src/commands/agent.ts
830
- import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
831
- import { resolve as resolve3 } from "path";
832
-
833
- // src/agent-kit.ts
834
- function recommendedMcp(tool) {
835
- return mcpForTool(tool);
836
- }
837
- function mergeMcpServers(existing, add) {
838
- const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
839
- for (const [name, val] of Object.entries(add)) {
840
- if (out.mcpServers[name]) continue;
841
- out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
842
- }
843
- return out;
844
- }
845
-
846
- // src/commands/agent.ts
847
- var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
848
-
849
- This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
850
- tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
851
- branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
852
- push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
853
- Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
854
- pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
855
-
856
- Agentic kit:
857
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
858
- - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
859
- Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
860
- - Best-practice skills (one-time, user scope):
861
- \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
862
- `;
863
- function materializeAgentKit(dir, tool) {
864
- const src = skillAssetDir();
865
- if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
866
- const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
867
- mkdirSync(dest, { recursive: true });
868
- cpSync(src, dest, { recursive: true });
869
- console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
870
- for (const pack of packsForTool(tool)) {
871
- if (!pack.skill) continue;
872
- const skillSrc = skillAssetDir(pack.skill);
873
- if (!existsSync3(skillSrc)) continue;
874
- const skillDest = resolve3(dir, ".claude/skills", pack.skill);
875
- mkdirSync(skillDest, { recursive: true });
876
- cpSync(skillSrc, skillDest, { recursive: true });
877
- console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
878
- }
879
- const mcpPath = resolve3(dir, ".mcp.json");
880
- const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
881
- const servers = recommendedMcp(tool);
882
- writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
883
- `);
884
- console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
885
- const claudePath = resolve3(dir, "CLAUDE.md");
886
- const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
887
- const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
888
- if (existing.includes(marker)) {
889
- console.log("\xB7 CLAUDE.md already has the loop block");
890
- } else {
891
- writeFileSync(claudePath, existing ? `${existing.trimEnd()}
892
-
893
- ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
894
- console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
895
- }
896
- }
897
- async function agentCommand(args) {
898
- if (args[0] !== "sync") {
899
- console.log(
900
- "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"
901
- );
902
- process.exit(args[0] ? 1 : 0);
903
- }
904
- const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
905
- if (name) {
906
- const { config } = await loadManifest();
907
- const entry = resolveEntry(config, name);
908
- const dir = resolve3(process.cwd(), entry.dir ?? ".");
909
- materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
910
- console.log(
911
- `
912
- Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
913
- );
914
- return;
915
- }
916
- materializeAgentKit(process.cwd());
917
- console.log(
918
- "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
919
- );
920
- }
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";
921
922
 
922
923
  // src/commands/secrets.ts
923
924
  import { execFileSync } from "child_process";
924
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
925
- import { resolve as resolve4 } from "path";
925
+ import { existsSync as existsSync3, readFileSync } from "fs";
926
+ import { resolve as resolve3 } from "path";
926
927
  import { createInterface } from "readline";
927
928
  function parseSecretsEnv(text) {
928
929
  const out = [];
@@ -948,7 +949,7 @@ function parseOciConfig(text) {
948
949
  return out;
949
950
  }
950
951
  function ociPrefill(configPath, keyPath) {
951
- const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
952
+ const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
952
953
  const map = /* @__PURE__ */ new Map();
953
954
  const set = (k, v) => {
954
955
  if (v) map.set(k, v);
@@ -958,8 +959,8 @@ function ociPrefill(configPath, keyPath) {
958
959
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
959
960
  set("TF_VAR_OCI_REGION", cfg.region);
960
961
  const pem = keyPath ?? cfg.key_file;
961
- if (pem && existsSync4(pem)) {
962
- map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
962
+ if (pem && existsSync3(pem)) {
963
+ map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
963
964
  } else if (pem) {
964
965
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
965
966
  }
@@ -993,11 +994,11 @@ function syncSecrets(opts) {
993
994
  "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
994
995
  );
995
996
  }
996
- const path = resolve4(opts.cwd, ".greenlight/secrets.env");
997
- if (!existsSync4(path)) {
997
+ const path = resolve3(opts.cwd, ".greenlight/secrets.env");
998
+ if (!existsSync3(path)) {
998
999
  throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
999
1000
  }
1000
- const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
1001
+ const entries = parseSecretsEnv(readFileSync(path, "utf8"));
1001
1002
  const target = opts.env ? `env "${opts.env}"` : "repo";
1002
1003
  for (const { key, value } of entries) {
1003
1004
  const ghArgs = ["secret", "set", key, "--repo", repo];
@@ -1138,7 +1139,7 @@ async function secretsCommand(args) {
1138
1139
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
1139
1140
  const ociConfig2 = flag(args, "--oci-config");
1140
1141
  const ociKey = flag(args, "--oci-key");
1141
- const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
1142
+ const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
1142
1143
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
1143
1144
  return;
1144
1145
  }
@@ -1163,6 +1164,177 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
1163
1164
  );
1164
1165
  }
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
+ 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."
1335
+ );
1336
+ }
1337
+
1166
1338
  // src/commands/add.ts
1167
1339
  function flag2(args, name) {
1168
1340
  const i = args.indexOf(name);
@@ -1174,25 +1346,25 @@ function templateDir(lane, target) {
1174
1346
  }
1175
1347
  function registerWorkspaceMember(cwd, member) {
1176
1348
  const wsPath = join(cwd, "pnpm-workspace.yaml");
1177
- if (!existsSync5(wsPath)) {
1178
- writeFileSync2(wsPath, `packages:
1349
+ if (!existsSync6(wsPath)) {
1350
+ writeFileSync3(wsPath, `packages:
1179
1351
  - "${member}"
1180
1352
  `);
1181
1353
  console.log(`\u2714 created pnpm-workspace.yaml (member ${member})`);
1182
1354
  return;
1183
1355
  }
1184
- const text = readFileSync3(wsPath, "utf8");
1356
+ const text = readFileSync4(wsPath, "utf8");
1185
1357
  if (text.includes(member) || /^\s*-\s*["']?tools\/\*/m.test(text)) return;
1186
1358
  const lines = text.split("\n");
1187
1359
  const pkgIdx = lines.findIndex((l) => /^packages\s*:/.test(l));
1188
1360
  if (pkgIdx === -1) {
1189
- writeFileSync2(wsPath, `${text.replace(/\s*$/, "")}
1361
+ writeFileSync3(wsPath, `${text.replace(/\s*$/, "")}
1190
1362
  packages:
1191
1363
  - "${member}"
1192
1364
  `);
1193
1365
  } else {
1194
1366
  lines.splice(pkgIdx + 1, 0, ` - "${member}"`);
1195
- writeFileSync2(wsPath, lines.join("\n"));
1367
+ writeFileSync3(wsPath, lines.join("\n"));
1196
1368
  }
1197
1369
  console.log(`\u2714 registered ${member} in pnpm-workspace.yaml`);
1198
1370
  }
@@ -1226,48 +1398,60 @@ async function addCommand(args) {
1226
1398
  const data = entry?.data ?? "none";
1227
1399
  const envs = entry?.envs ?? ["beta", "prod"];
1228
1400
  const toolInfo = { lane, target, data };
1229
- const dest = resolve5(process.cwd(), "tools", name);
1230
- if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
1401
+ const dest = resolve6(process.cwd(), "tools", name);
1402
+ if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
1231
1403
  const src = templateDir(lane, target);
1232
- if (existsSync5(src)) {
1404
+ if (existsSync6(src)) {
1233
1405
  cpSync2(src, dest, { recursive: true });
1234
1406
  const pkgPath = join(dest, "package.json");
1235
- if (existsSync5(pkgPath)) {
1236
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1407
+ if (existsSync6(pkgPath)) {
1408
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1237
1409
  pkg.name = name;
1238
- writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1410
+ writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1239
1411
  `);
1240
1412
  }
1241
1413
  const shippedGitignore = join(dest, "gitignore");
1242
- if (existsSync5(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1414
+ if (existsSync6(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1243
1415
  const wranglerPath = join(dest, "wrangler.toml");
1244
- if (existsSync5(wranglerPath)) {
1245
- const wt = readFileSync3(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1246
- writeFileSync2(wranglerPath, wt);
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);
1247
1431
  }
1248
1432
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1249
- if (existsSync5(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1433
+ if (existsSync6(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1250
1434
  } else {
1251
1435
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1252
1436
  }
1253
- writeFileSync2(path, serializeConfig(next));
1437
+ writeFileSync3(path, serializeConfig(next));
1254
1438
  console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
1255
1439
  const cwd = process.cwd();
1256
1440
  const providers = providersForTool(toolInfo);
1257
- const infraDir = resolve5(cwd, "infra");
1441
+ const infraDir = resolve6(cwd, "infra");
1258
1442
  const mainTf = join(infraDir, "main.tf");
1259
- if (!existsSync5(mainTf)) {
1260
- mkdirSync2(infraDir, { recursive: true });
1261
- writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1443
+ if (!existsSync6(mainTf)) {
1444
+ mkdirSync3(infraDir, { recursive: true });
1445
+ writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1262
1446
  console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
1263
1447
  } else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
1264
1448
  console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
1265
1449
  }
1266
1450
  const toolTf = join(infraDir, `${name}.tf`);
1267
- if (existsSync5(toolTf)) {
1451
+ if (existsSync6(toolTf)) {
1268
1452
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1269
1453
  } else {
1270
- writeFileSync2(
1454
+ writeFileSync3(
1271
1455
  toolTf,
1272
1456
  emitToolTf({
1273
1457
  name,
@@ -1283,6 +1467,17 @@ async function addCommand(args) {
1283
1467
  );
1284
1468
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1285
1469
  }
1470
+ if (lane === "agent") {
1471
+ const wfDir = resolve6(cwd, ".github/workflows");
1472
+ const wfPath = join(wfDir, `deploy-${name}.yml`);
1473
+ if (existsSync6(wfPath)) {
1474
+ console.log(`\xB7 .github/workflows/deploy-${name}.yml exists \u2014 left as-is`);
1475
+ } else {
1476
+ mkdirSync3(wfDir, { recursive: true });
1477
+ writeFileSync3(wfPath, emitAgentDeployWorkflow(name, config.domain));
1478
+ console.log(`\u2714 wrote .github/workflows/deploy-${name}.yml`);
1479
+ }
1480
+ }
1286
1481
  materializeAgentKit(cwd, toolInfo);
1287
1482
  const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
1288
1483
  const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
@@ -1305,8 +1500,8 @@ Next:${gather ? "" : `
1305
1500
 
1306
1501
  // src/commands/adopt.ts
1307
1502
  import { execFileSync as execFileSync2 } from "child_process";
1308
- import { cpSync as cpSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
1309
- import { join as join2, resolve as resolve6 } from "path";
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";
1310
1505
  var REF = MODULE_REF;
1311
1506
  function flag3(args, name) {
1312
1507
  const i = args.indexOf(name);
@@ -1322,7 +1517,7 @@ function mergePackageJson(existing, repoName, vendor) {
1322
1517
  }
1323
1518
  function vendorDeps(vendorDir) {
1324
1519
  const out = {};
1325
- if (!existsSync6(vendorDir)) return out;
1520
+ if (!existsSync7(vendorDir)) return out;
1326
1521
  for (const f of readdirSync(vendorDir)) {
1327
1522
  if (!f.endsWith(".tgz")) continue;
1328
1523
  const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
@@ -1777,12 +1972,12 @@ export default [api, ...agentWeb];
1777
1972
  `;
1778
1973
  }
1779
1974
  function writeIfAbsent(path, contents, label) {
1780
- if (existsSync6(path)) {
1975
+ if (existsSync7(path)) {
1781
1976
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
1782
1977
  return;
1783
1978
  }
1784
- mkdirSync3(resolve6(path, ".."), { recursive: true });
1785
- writeFileSync3(path, contents);
1979
+ mkdirSync4(resolve7(path, ".."), { recursive: true });
1980
+ writeFileSync4(path, contents);
1786
1981
  console.log(`\u2714 ${label}`);
1787
1982
  }
1788
1983
  async function adoptCommand(args) {
@@ -1818,10 +2013,10 @@ async function adoptWrapper(ctx) {
1818
2013
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1819
2014
  const cwd = process.cwd();
1820
2015
  const toolRel = `tools/${name}`;
1821
- const dest = resolve6(cwd, toolRel);
2016
+ const dest = resolve7(cwd, toolRel);
1822
2017
  console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
1823
2018
  `);
1824
- if (!existsSync6(dest)) {
2019
+ if (!existsSync7(dest)) {
1825
2020
  try {
1826
2021
  execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
1827
2022
  console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
@@ -1857,7 +2052,7 @@ async function adoptWrapper(ctx) {
1857
2052
  }
1858
2053
  } : {}
1859
2054
  });
1860
- writeFileSync3(regPath, serializeConfig(nextReg));
2055
+ writeFileSync4(regPath, serializeConfig(nextReg));
1861
2056
  console.log(
1862
2057
  `\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
1863
2058
  );
@@ -1875,7 +2070,7 @@ async function adoptWrapper(ctx) {
1875
2070
  );
1876
2071
  }
1877
2072
  const providers = providersForTool({ lane, target, data });
1878
- if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
2073
+ if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1879
2074
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
1880
2075
  }
1881
2076
  materializeAgentKit(dest, { lane, target, data });
@@ -1927,9 +2122,9 @@ Next:
1927
2122
  }
1928
2123
  async function adoptStandalone(ctx) {
1929
2124
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1930
- const repo = resolve6(process.cwd(), repoArg);
1931
- if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1932
- const regVendor = resolve6(process.cwd(), "vendor");
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");
1933
2128
  const vendor = vendorDeps(regVendor);
1934
2129
  if (Object.keys(vendor).length === 0) {
1935
2130
  throw new Error(
@@ -1947,15 +2142,15 @@ async function adoptStandalone(ctx) {
1947
2142
  );
1948
2143
  const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
1949
2144
  const pkgPath = join2(repo, "package.json");
1950
- const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
1951
- writeFileSync3(
2145
+ const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
2146
+ writeFileSync4(
1952
2147
  pkgPath,
1953
2148
  `${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
1954
2149
  `
1955
2150
  );
1956
2151
  console.log("\u2714 package.json (merged framework deps + overrides)");
1957
2152
  const repoVendor = join2(repo, "vendor");
1958
- mkdirSync3(repoVendor, { recursive: true });
2153
+ mkdirSync4(repoVendor, { recursive: true });
1959
2154
  for (const f of readdirSync(regVendor)) {
1960
2155
  if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
1961
2156
  }
@@ -1989,7 +2184,7 @@ async function adoptStandalone(ctx) {
1989
2184
  external: true,
1990
2185
  adopted: true
1991
2186
  });
1992
- writeFileSync3(regPath, serializeConfig(nextReg));
2187
+ writeFileSync4(regPath, serializeConfig(nextReg));
1993
2188
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
1994
2189
  console.log(`
1995
2190
  Next (in the adopted repo):
@@ -2002,20 +2197,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
2002
2197
  }
2003
2198
  function addGreenlightScript(dir) {
2004
2199
  const pkgPath = join2(dir, "package.json");
2005
- if (!existsSync6(pkgPath)) {
2200
+ if (!existsSync7(pkgPath)) {
2006
2201
  console.log(
2007
2202
  "\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
2008
2203
  );
2009
2204
  return;
2010
2205
  }
2011
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2206
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
2012
2207
  pkg.scripts = { ...pkg.scripts ?? {} };
2013
2208
  if (pkg.scripts.greenlight) {
2014
2209
  console.log("\xB7 package.json already has a greenlight script");
2015
2210
  return;
2016
2211
  }
2017
2212
  pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
2018
- writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
2213
+ writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
2019
2214
  `);
2020
2215
  console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
2021
2216
  }
@@ -2160,10 +2355,10 @@ async function deployCommand(args) {
2160
2355
  // src/commands/doctor.ts
2161
2356
  import { execFileSync as execFileSync4 } from "child_process";
2162
2357
  import { lookup } from "dns/promises";
2163
- import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
2358
+ import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
2164
2359
  import { join as join4 } from "path";
2165
2360
  function dirCheck(label, dir) {
2166
- return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2361
+ return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2167
2362
  }
2168
2363
  function conformanceChecks(t, root) {
2169
2364
  const out = [];
@@ -2173,7 +2368,7 @@ function conformanceChecks(t, root) {
2173
2368
  join4(toolDir, `verify/${t.name}.config.ts`),
2174
2369
  join4(toolDir, "verify.config.ts")
2175
2370
  ] : [join4(toolDir, "verify.config.ts")];
2176
- const found = specCandidates.find((p) => existsSync7(join4(root, p)));
2371
+ const found = specCandidates.find((p) => existsSync8(join4(root, p)));
2177
2372
  out.push({
2178
2373
  name: `${t.name}: in the verify loop`,
2179
2374
  status: found ? "ok" : "warn",
@@ -2199,14 +2394,14 @@ function conformanceChecks(t, root) {
2199
2394
  }
2200
2395
  if (!t.external && t.lane === "next" && t.target === "vercel") {
2201
2396
  const wsPath = join4(root, "pnpm-workspace.yaml");
2202
- const ws = existsSync7(wsPath) ? readFileSync5(wsPath, "utf8") : "";
2397
+ const ws = existsSync8(wsPath) ? readFileSync6(wsPath, "utf8") : "";
2203
2398
  const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
2204
2399
  out.push({
2205
2400
  name: `${t.name}: pnpm workspace member`,
2206
2401
  status: member ? "ok" : "warn",
2207
2402
  detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
2208
2403
  });
2209
- const hasVercelJson = existsSync7(join4(root, toolDir, "vercel.json"));
2404
+ const hasVercelJson = existsSync8(join4(root, toolDir, "vercel.json"));
2210
2405
  out.push({
2211
2406
  name: `${t.name}: vercel.json framework`,
2212
2407
  status: hasVercelJson ? "ok" : "warn",
@@ -2220,7 +2415,7 @@ function versionDriftCheck(root) {
2220
2415
  let installed;
2221
2416
  try {
2222
2417
  const pkg = JSON.parse(
2223
- readFileSync5(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2418
+ readFileSync6(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2224
2419
  );
2225
2420
  installed = pkg.version;
2226
2421
  } catch {
@@ -2228,7 +2423,7 @@ function versionDriftCheck(root) {
2228
2423
  const refs = /* @__PURE__ */ new Set();
2229
2424
  try {
2230
2425
  for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
2231
- const body = readFileSync5(join4(root, "infra", f), "utf8");
2426
+ const body = readFileSync6(join4(root, "infra", f), "utf8");
2232
2427
  for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
2233
2428
  if (m[1]) refs.add(m[1]);
2234
2429
  }
@@ -2284,7 +2479,7 @@ function runDoctor(config, root) {
2284
2479
  checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
2285
2480
  if (t.dir) {
2286
2481
  checks.push(
2287
- existsSync7(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2482
+ existsSync8(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2288
2483
  name: `${t.name}: dir present`,
2289
2484
  status: "warn",
2290
2485
  detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
@@ -2378,89 +2573,6 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2378
2573
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2379
2574
  import { resolve as resolve8 } from "path";
2380
2575
  import { createInterface as createInterface3 } from "readline/promises";
2381
-
2382
- // src/tokens.ts
2383
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2384
- import { resolve as resolve7 } from "path";
2385
- import { createInterface as createInterface2 } from "readline/promises";
2386
- var SECRETS_DIR = ".greenlight";
2387
- var SECRETS_FILE = "secrets.env";
2388
- function presentEnv(cwd) {
2389
- const out = {};
2390
- const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
2391
- if (existsSync8(p)) {
2392
- for (const { key, value } of parseSecretsEnv(readFileSync6(p, "utf8"))) out[key] = value;
2393
- }
2394
- for (const [k, v] of Object.entries(process.env)) {
2395
- if (v !== void 0 && !(k in out)) out[k] = v;
2396
- }
2397
- return out;
2398
- }
2399
- function upsertSecret(cwd, key, value) {
2400
- const dir = resolve7(cwd, SECRETS_DIR);
2401
- mkdirSync4(dir, { recursive: true });
2402
- const p = resolve7(dir, SECRETS_FILE);
2403
- const lines = existsSync8(p) ? readFileSync6(p, "utf8").split("\n") : [];
2404
- const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
2405
- if (idx >= 0) lines[idx] = `${key}=${value}`;
2406
- else {
2407
- while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
2408
- lines.push(`${key}=${value}`);
2409
- }
2410
- writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
2411
- `, { mode: 384 });
2412
- }
2413
- async function ensureTokensForTool(cwd, tool, opts = {}) {
2414
- const doVerify = opts.verify !== false;
2415
- const interactive = Boolean(process.stdin.isTTY);
2416
- const env = presentEnv(cwd);
2417
- const results = [];
2418
- const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
2419
- try {
2420
- for (const spec of tokensForTool(tool)) {
2421
- let value = env[spec.envVar];
2422
- if (value) {
2423
- results.push({ envVar: spec.envVar, outcome: "present" });
2424
- } else if (rl) {
2425
- console.log(`
2426
- ${spec.envVar} \u2014 ${spec.label}`);
2427
- if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
2428
- const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
2429
- if (!entered) {
2430
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2431
- continue;
2432
- }
2433
- upsertSecret(cwd, spec.envVar, entered);
2434
- env[spec.envVar] = entered;
2435
- value = entered;
2436
- results.push({ envVar: spec.envVar, outcome: "entered" });
2437
- } else {
2438
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2439
- continue;
2440
- }
2441
- if (value && doVerify && spec.verify) {
2442
- let check;
2443
- try {
2444
- check = await spec.verify(value, env);
2445
- } catch (e) {
2446
- check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
2447
- }
2448
- const last = results[results.length - 1];
2449
- if (last) last.verify = check;
2450
- if (!check.ok && !spec.optional) {
2451
- throw new Error(
2452
- `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
2453
- );
2454
- }
2455
- }
2456
- }
2457
- } finally {
2458
- rl?.close();
2459
- }
2460
- return results;
2461
- }
2462
-
2463
- // src/commands/init.ts
2464
2576
  function flag5(args, name) {
2465
2577
  const i = args.indexOf(name);
2466
2578
  return i >= 0 ? args[i + 1] : void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.3.0",
3
+ "version": "0.3.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.0",
35
- "@rtrentjones/greenlight-loop": "0.3.0",
36
- "@rtrentjones/greenlight-verify": "0.3.0",
37
- "@rtrentjones/greenlight-shared": "0.3.0"
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"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",
@@ -1,9 +1,11 @@
1
- # Agent Worker. `greenlight add` rewrites `name` (agent-tool your tool) + the route domain
2
- # (example.dev your domain). You still set the KV namespace id (create it once with
3
- # `wrangler kv namespace create STATE`) and the secrets:
4
- # wrangler secret put GEMINI_API_KEY --env prod (Google AI Studio key — free tier)
5
- # wrangler secret put RUN_TOKEN --env prod (any random string; bearer for POST /run)
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.
6
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.
8
+ account_id = "REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID"
7
9
  main = "src/index.ts"
8
10
  compatibility_date = "2025-06-01"
9
11