@rtrentjones/greenlight 0.3.0 → 0.4.0

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
 
@@ -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]
@@ -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
@@ -18,6 +18,95 @@ import "./chunk-QFKE5JKC.js";
18
18
  import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
19
19
  import { join, resolve as resolve5 } from "path";
20
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
+ }
109
+
21
110
  // src/asset-paths.ts
22
111
  import { existsSync } from "fs";
23
112
  import { dirname, resolve } from "path";
@@ -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.4.0";
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,115 +915,11 @@ 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
- }
921
-
922
918
  // src/commands/secrets.ts
923
919
  import { execFileSync } from "child_process";
924
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
925
- import { resolve as resolve4 } from "path";
920
+ import { existsSync as existsSync3, readFileSync } from "fs";
921
+ import { resolve as resolve3 } from "path";
926
922
  import { createInterface } from "readline";
927
- function parseSecretsEnv(text) {
928
- const out = [];
929
- for (const raw of text.split("\n")) {
930
- const line = raw.trim();
931
- if (line === "" || line.startsWith("#")) continue;
932
- const eq = line.indexOf("=");
933
- if (eq <= 0) continue;
934
- out.push({ key: line.slice(0, eq).trim(), value: line.slice(eq + 1) });
935
- }
936
- return out;
937
- }
938
923
  function parseOciConfig(text) {
939
924
  const out = {};
940
925
  for (const raw of text.split("\n")) {
@@ -948,7 +933,7 @@ function parseOciConfig(text) {
948
933
  return out;
949
934
  }
950
935
  function ociPrefill(configPath, keyPath) {
951
- const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
936
+ const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
952
937
  const map = /* @__PURE__ */ new Map();
953
938
  const set = (k, v) => {
954
939
  if (v) map.set(k, v);
@@ -958,8 +943,8 @@ function ociPrefill(configPath, keyPath) {
958
943
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
959
944
  set("TF_VAR_OCI_REGION", cfg.region);
960
945
  const pem = keyPath ?? cfg.key_file;
961
- if (pem && existsSync4(pem)) {
962
- map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
946
+ if (pem && existsSync3(pem)) {
947
+ map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
963
948
  } else if (pem) {
964
949
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
965
950
  }
@@ -986,49 +971,17 @@ function detectRepo(cwd) {
986
971
  return null;
987
972
  }
988
973
  }
989
- function syncSecrets(opts) {
990
- const repo = opts.repo ?? detectRepo(opts.cwd);
991
- if (!repo) {
992
- throw new Error(
993
- "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
994
- );
995
- }
996
- const path = resolve4(opts.cwd, ".greenlight/secrets.env");
997
- if (!existsSync4(path)) {
998
- throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
999
- }
1000
- const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
1001
- const target = opts.env ? `env "${opts.env}"` : "repo";
1002
- for (const { key, value } of entries) {
1003
- const ghArgs = ["secret", "set", key, "--repo", repo];
1004
- if (opts.env) ghArgs.push("--env", opts.env);
1005
- try {
1006
- execFileSync("gh", ghArgs, { input: value });
1007
- } catch (e) {
1008
- const err = e;
1009
- if (err.code === "ENOENT") {
1010
- throw new Error("the GitHub CLI `gh` is required \u2014 install it and run `gh auth login`");
1011
- }
1012
- const detail = err.stderr?.toString().trim();
1013
- throw new Error(
1014
- `failed to set ${key}${detail ? `: ${detail}` : " (check `gh auth status`)"}`
1015
- );
1016
- }
1017
- console.log(`\u2714 set ${key} \u2192 ${repo} ${target}`);
1018
- }
1019
- return { repo, count: entries.length };
1020
- }
1021
974
  function hiddenPrompter() {
1022
975
  const tty = Boolean(process.stdin.isTTY);
1023
976
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
1024
977
  if (tty) rl._writeToOutput = () => {
1025
978
  };
1026
979
  return {
1027
- ask: (query) => new Promise((resolve11) => {
980
+ ask: (query) => new Promise((resolve10) => {
1028
981
  process.stdout.write(query);
1029
982
  rl.question("", (val) => {
1030
983
  process.stdout.write("\n");
1031
- resolve11(val.trim());
984
+ resolve10(val.trim());
1032
985
  });
1033
986
  }),
1034
987
  close: () => rl.close()
@@ -1138,28 +1091,168 @@ async function secretsCommand(args) {
1138
1091
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
1139
1092
  const ociConfig2 = flag(args, "--oci-config");
1140
1093
  const ociKey = flag(args, "--oci-key");
1141
- const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
1094
+ const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
1142
1095
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
1143
1096
  return;
1144
1097
  }
1145
- if (sub !== "sync") {
1098
+ console.log(
1099
+ "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"
1100
+ );
1101
+ process.exit(sub ? 1 : 0);
1102
+ }
1103
+
1104
+ // src/tokens.ts
1105
+ function presentEnv() {
1106
+ const out = {};
1107
+ for (const [k, v] of Object.entries(process.env)) {
1108
+ if (v !== void 0) out[k] = v;
1109
+ }
1110
+ return out;
1111
+ }
1112
+ async function ensureTokensForTool(repo, tool, opts = {}) {
1113
+ const doVerify = opts.verify !== false;
1114
+ const env = presentEnv();
1115
+ const already = listGitHubSecrets(repo, opts.env);
1116
+ const results = [];
1117
+ const prompt = process.stdin.isTTY ? hiddenPrompter() : null;
1118
+ try {
1119
+ for (const spec of tokensForTool(tool)) {
1120
+ const key = secretKeyFor(spec, "", void 0);
1121
+ if (key === "GITHUB_TOKEN") {
1122
+ results.push({ envVar: spec.envVar, outcome: "skipped" });
1123
+ continue;
1124
+ }
1125
+ if (env[spec.envVar] || already?.has(key)) {
1126
+ results.push({ envVar: spec.envVar, outcome: "present" });
1127
+ continue;
1128
+ }
1129
+ if (!prompt) {
1130
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1131
+ continue;
1132
+ }
1133
+ console.log(`
1134
+ ${key} \u2014 ${spec.label}`);
1135
+ if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
1136
+ const entered = await prompt.ask(
1137
+ ` value${spec.optional ? " (optional, Enter to skip)" : ""}: `
1138
+ );
1139
+ if (!entered) {
1140
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1141
+ continue;
1142
+ }
1143
+ env[spec.envVar] = entered;
1144
+ let check;
1145
+ if (doVerify && spec.verify) {
1146
+ try {
1147
+ check = await spec.verify(entered, env);
1148
+ } catch (e) {
1149
+ check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
1150
+ }
1151
+ if (!check.ok && !spec.optional) {
1152
+ throw new Error(
1153
+ `${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
1154
+ );
1155
+ }
1156
+ }
1157
+ setGitHubSecret(repo, opts.env, key, entered);
1158
+ results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
1159
+ }
1160
+ } finally {
1161
+ prompt?.close();
1162
+ }
1163
+ return results;
1164
+ }
1165
+
1166
+ // src/commands/agent.ts
1167
+ import { cpSync, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
1168
+ import { resolve as resolve4 } from "path";
1169
+
1170
+ // src/agent-kit.ts
1171
+ function recommendedMcp(tool) {
1172
+ return mcpForTool(tool);
1173
+ }
1174
+ function mergeMcpServers(existing, add) {
1175
+ const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
1176
+ for (const [name, val] of Object.entries(add)) {
1177
+ if (out.mcpServers[name]) continue;
1178
+ out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
1179
+ }
1180
+ return out;
1181
+ }
1182
+
1183
+ // src/commands/agent.ts
1184
+ var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
1185
+
1186
+ This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
1187
+ tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
1188
+ branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
1189
+ push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
1190
+ Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
1191
+ pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
1192
+
1193
+ Agentic kit:
1194
+ - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
1195
+ - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
1196
+ Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
1197
+ - Best-practice skills (one-time, user scope):
1198
+ \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
1199
+ `;
1200
+ function materializeAgentKit(dir, tool) {
1201
+ const src = skillAssetDir();
1202
+ if (!existsSync4(src)) throw new Error(`skill asset not found at ${src}`);
1203
+ const dest = resolve4(dir, ".claude/skills/deploy-verify-promote");
1204
+ mkdirSync(dest, { recursive: true });
1205
+ cpSync(src, dest, { recursive: true });
1206
+ console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
1207
+ for (const pack of packsForTool(tool)) {
1208
+ if (!pack.skill) continue;
1209
+ const skillSrc = skillAssetDir(pack.skill);
1210
+ if (!existsSync4(skillSrc)) continue;
1211
+ const skillDest = resolve4(dir, ".claude/skills", pack.skill);
1212
+ mkdirSync(skillDest, { recursive: true });
1213
+ cpSync(skillSrc, skillDest, { recursive: true });
1214
+ console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
1215
+ }
1216
+ const mcpPath = resolve4(dir, ".mcp.json");
1217
+ const existingMcp = existsSync4(mcpPath) ? JSON.parse(readFileSync2(mcpPath, "utf8")) : null;
1218
+ const servers = recommendedMcp(tool);
1219
+ writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
1220
+ `);
1221
+ console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
1222
+ const claudePath = resolve4(dir, "CLAUDE.md");
1223
+ const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
1224
+ const existing = existsSync4(claudePath) ? readFileSync2(claudePath, "utf8") : "";
1225
+ if (existing.includes(marker)) {
1226
+ console.log("\xB7 CLAUDE.md already has the loop block");
1227
+ } else {
1228
+ writeFileSync(claudePath, existing ? `${existing.trimEnd()}
1229
+
1230
+ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
1231
+ console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
1232
+ }
1233
+ }
1234
+ async function agentCommand(args) {
1235
+ if (args[0] !== "sync") {
1146
1236
  console.log(
1147
- "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"
1237
+ "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"
1148
1238
  );
1149
- process.exit(sub ? 1 : 0);
1239
+ process.exit(args[0] ? 1 : 0);
1150
1240
  }
1151
- const { count } = syncSecrets({
1152
- cwd: process.cwd(),
1153
- repo: flag(args, "--repo"),
1154
- env: flag(args, "--env")
1155
- });
1156
- if (count === 0) {
1157
- console.log("no secrets to sync");
1241
+ const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
1242
+ if (name) {
1243
+ const { config } = await loadManifest();
1244
+ const entry = resolveEntry(config, name);
1245
+ const dir = resolve4(process.cwd(), entry.dir ?? ".");
1246
+ materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
1247
+ console.log(
1248
+ `
1249
+ Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
1250
+ );
1158
1251
  return;
1159
1252
  }
1253
+ materializeAgentKit(process.cwd());
1160
1254
  console.log(
1161
- `
1162
- ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supported.)`
1255
+ "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1163
1256
  );
1164
1257
  }
1165
1258
 
@@ -1242,7 +1335,19 @@ async function addCommand(args) {
1242
1335
  if (existsSync5(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1243
1336
  const wranglerPath = join(dest, "wrangler.toml");
1244
1337
  if (existsSync5(wranglerPath)) {
1245
- const wt = readFileSync3(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1338
+ let wt = readFileSync3(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1339
+ if (wt.includes("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID")) {
1340
+ const token = presentEnv().CLOUDFLARE_API_TOKEN;
1341
+ const acct = token ? await resolveCloudflareAccountId(config.domain, token) : null;
1342
+ if (acct) {
1343
+ wt = wt.replaceAll("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID", acct);
1344
+ console.log("\u2714 resolved the Cloudflare account id into wrangler.toml");
1345
+ } else {
1346
+ console.log(
1347
+ "\xB7 could not resolve the Cloudflare account id \u2014 set account_id in wrangler.toml"
1348
+ );
1349
+ }
1350
+ }
1246
1351
  writeFileSync2(wranglerPath, wt);
1247
1352
  }
1248
1353
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
@@ -1283,6 +1388,17 @@ async function addCommand(args) {
1283
1388
  );
1284
1389
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1285
1390
  }
1391
+ if (lane === "agent") {
1392
+ const wfDir = resolve5(cwd, ".github/workflows");
1393
+ const wfPath = join(wfDir, `deploy-${name}.yml`);
1394
+ if (existsSync5(wfPath)) {
1395
+ console.log(`\xB7 .github/workflows/deploy-${name}.yml exists \u2014 left as-is`);
1396
+ } else {
1397
+ mkdirSync2(wfDir, { recursive: true });
1398
+ writeFileSync2(wfPath, emitAgentDeployWorkflow(name, config.domain));
1399
+ console.log(`\u2714 wrote .github/workflows/deploy-${name}.yml`);
1400
+ }
1401
+ }
1286
1402
  materializeAgentKit(cwd, toolInfo);
1287
1403
  const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
1288
1404
  const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
@@ -2375,92 +2491,9 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2375
2491
  }
2376
2492
 
2377
2493
  // src/commands/init.ts
2378
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2379
- import { resolve as resolve8 } from "path";
2380
- 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";
2494
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2384
2495
  import { resolve as resolve7 } from "path";
2385
2496
  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
2497
  function flag5(args, name) {
2465
2498
  const i = args.indexOf(name);
2466
2499
  return i >= 0 ? args[i + 1] : void 0;
@@ -2482,7 +2515,7 @@ function wrapperPackageJson(name) {
2482
2515
  }
2483
2516
  var WRAPPER_GITIGNORE = `# Greenlight wrapper
2484
2517
  node_modules/
2485
- .greenlight/ # gathered tokens \u2014 never committed
2518
+ .greenlight/ # local scratch \u2014 never committed (tokens live in GitHub Actions)
2486
2519
  .terraform/
2487
2520
  *.tfplan
2488
2521
  tf.plan
@@ -2544,12 +2577,12 @@ jobs:
2544
2577
  `;
2545
2578
  }
2546
2579
  function scaffoldIfAbsent(path, contents, label) {
2547
- if (existsSync9(path)) {
2580
+ if (existsSync8(path)) {
2548
2581
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
2549
2582
  return;
2550
2583
  }
2551
- mkdirSync5(resolve8(path, ".."), { recursive: true });
2552
- writeFileSync5(path, contents);
2584
+ mkdirSync4(resolve7(path, ".."), { recursive: true });
2585
+ writeFileSync4(path, contents);
2553
2586
  console.log(`\u2714 wrote ${label}`);
2554
2587
  }
2555
2588
  var TOKEN_FLAGS = {
@@ -2564,70 +2597,74 @@ async function initCommand(args) {
2564
2597
  let domain = flag5(args, "--domain");
2565
2598
  if (!domain) {
2566
2599
  if (!process.stdin.isTTY) throw new Error("init needs --domain <domain> (no TTY for prompts)");
2567
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
2600
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
2568
2601
  domain = (await rl.question("Domain (e.g. example.dev): ")).trim();
2569
2602
  rl.close();
2570
2603
  }
2571
2604
  if (!domain) throw new Error("a domain is required");
2572
2605
  const cwd = process.cwd();
2573
- const configPath = resolve8(cwd, "greenlight.config.ts");
2574
- if (existsSync9(configPath) && !force) {
2606
+ const configPath = resolve7(cwd, "greenlight.config.ts");
2607
+ if (existsSync8(configPath) && !force) {
2575
2608
  throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
2576
2609
  }
2577
- writeFileSync5(configPath, scaffoldConfig(domain));
2610
+ writeFileSync4(configPath, scaffoldConfig(domain));
2578
2611
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2579
2612
  const repoName = domain.replace(/\./g, "-");
2580
2613
  scaffoldIfAbsent(
2581
- resolve8(cwd, ".github/workflows/infra.yml"),
2614
+ resolve7(cwd, ".github/workflows/infra.yml"),
2582
2615
  wrapperInfraYml(),
2583
2616
  ".github/workflows/infra.yml (HCP-backed terraform apply on push)"
2584
2617
  );
2585
- scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2586
- scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2587
- scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2588
- scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
2589
- const secrets = [];
2590
- for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
2591
- const v = flag5(args, f);
2592
- if (v) secrets.push(`${key}=${v}`);
2593
- }
2594
- if (secrets.length > 0) {
2595
- mkdirSync5(resolve8(cwd, ".greenlight"), { recursive: true });
2596
- writeFileSync5(resolve8(cwd, ".greenlight/secrets.env"), `${secrets.join("\n")}
2597
- `, {
2598
- mode: 384
2599
- });
2600
- console.log(`\u2714 wrote .greenlight/secrets.env (${secrets.length} token(s), gitignored)`);
2601
- }
2602
- if (process.stdin.isTTY && !args.includes("--no-tokens")) {
2603
- try {
2604
- await ensureTokensForTool(cwd, {}, { verify: !args.includes("--no-verify") });
2605
- } catch (e) {
2606
- console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
2618
+ scaffoldIfAbsent(resolve7(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2619
+ scaffoldIfAbsent(resolve7(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2620
+ scaffoldIfAbsent(resolve7(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2621
+ scaffoldIfAbsent(resolve7(cwd, ".node-version"), "24\n", ".node-version");
2622
+ const repo = flag5(args, "--repo") ?? detectRepo(cwd);
2623
+ let pushed = 0;
2624
+ if (repo && !args.includes("--no-push")) {
2625
+ for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
2626
+ const v = flag5(args, f);
2627
+ if (!v || key.startsWith("GITHUB_")) continue;
2628
+ try {
2629
+ setGitHubSecret(repo, void 0, key, v);
2630
+ console.log(`\u2714 set ${key} \u2192 ${repo} (GitHub Actions)`);
2631
+ pushed++;
2632
+ } catch (e) {
2633
+ console.log(`! could not set ${key}: ${e instanceof Error ? e.message : String(e)}`);
2634
+ }
2607
2635
  }
2608
2636
  }
2609
- let pushed = false;
2610
- if (existsSync9(resolve8(cwd, ".greenlight/secrets.env")) && !args.includes("--no-push")) {
2611
- try {
2612
- const { repo, count } = syncSecrets({ cwd, repo: flag5(args, "--repo") });
2613
- console.log(`\u2714 pushed ${count} secret(s) to ${repo} (GitHub Actions)`);
2614
- pushed = true;
2615
- } catch (e) {
2616
- console.log(`! skipped pushing secrets: ${e instanceof Error ? e.message : String(e)}`);
2617
- console.log(" run `greenlight secrets sync` once `gh` is authenticated.");
2637
+ if (process.stdin.isTTY && !args.includes("--no-tokens")) {
2638
+ if (repo) {
2639
+ try {
2640
+ const results = await ensureTokensForTool(
2641
+ repo,
2642
+ {},
2643
+ {
2644
+ verify: !args.includes("--no-verify")
2645
+ }
2646
+ );
2647
+ pushed += results.filter((r) => r.outcome === "entered").length;
2648
+ } catch (e) {
2649
+ console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
2650
+ }
2651
+ } else {
2652
+ console.log(
2653
+ "\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`."
2654
+ );
2618
2655
  }
2619
2656
  }
2620
2657
  console.log(`
2621
2658
  Next:
2622
2659
  1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
2623
- # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
2660
+ # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (it also prompts the base tokens if they are not set yet)"}
2624
2661
  2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state.md
2625
2662
  3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
2626
2663
  4. greenlight verify <name> --env prod | greenlight doctor`);
2627
2664
  }
2628
2665
 
2629
2666
  // src/commands/migrations.ts
2630
- import { existsSync as existsSync10, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
2667
+ import { existsSync as existsSync9, readFileSync as readFileSync6, readdirSync as readdirSync3 } from "fs";
2631
2668
  import { join as join5 } from "path";
2632
2669
  var DEFAULT_DIR = "supabase/migrations";
2633
2670
  var CANDIDATE_DIRS = [
@@ -2639,7 +2676,7 @@ var CANDIDATE_DIRS = [
2639
2676
  ];
2640
2677
  function resolveMigrationsDir(explicit, root = process.cwd()) {
2641
2678
  if (explicit) return explicit;
2642
- return CANDIDATE_DIRS.find((d) => existsSync10(join5(root, d))) ?? DEFAULT_DIR;
2679
+ return CANDIDATE_DIRS.find((d) => existsSync9(join5(root, d))) ?? DEFAULT_DIR;
2643
2680
  }
2644
2681
  async function migrationsCommand(args) {
2645
2682
  if (args[0] !== "scan") {
@@ -2665,7 +2702,7 @@ async function migrationsCommand(args) {
2665
2702
  }
2666
2703
  const files = names.map((f) => ({
2667
2704
  path: join5(dir, f),
2668
- content: readFileSync7(join5(dir, f), "utf8")
2705
+ content: readFileSync6(join5(dir, f), "utf8")
2669
2706
  }));
2670
2707
  const findings = scanSqlFiles(files);
2671
2708
  if (findings.length === 0) {
@@ -2690,12 +2727,12 @@ ${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn).
2690
2727
 
2691
2728
  // src/commands/preview.ts
2692
2729
  import { execFileSync as execFileSync5, spawn } from "child_process";
2693
- import { resolve as resolve10 } from "path";
2730
+ import { resolve as resolve9 } from "path";
2694
2731
  import { setTimeout as sleep } from "timers/promises";
2695
2732
 
2696
2733
  // src/commands/verify.ts
2697
2734
  import { spawnSync } from "child_process";
2698
- import { resolve as resolve9 } from "path";
2735
+ import { resolve as resolve8 } from "path";
2699
2736
  function defaultSpec(lane) {
2700
2737
  switch (lane) {
2701
2738
  case "astro":
@@ -2823,7 +2860,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
2823
2860
  if (reachableTimeoutMs > 0) {
2824
2861
  console.log(`waiting up to ${reachableTimeoutMs / 1e3}s for ${url} to become reachable\u2026`);
2825
2862
  }
2826
- const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2863
+ const toolDir = resolve8(process.cwd(), entry.dir ?? ".");
2827
2864
  const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
2828
2865
  attachFailureLogs(reports, specs, toolDir);
2829
2866
  for (const report of reports) printReport(report);
@@ -2867,7 +2904,7 @@ async function verifyLocal(entry, url) {
2867
2904
  process.env.GREENLIGHT_PREVIEW = "1";
2868
2905
  process.env.GREENLIGHT_VERIFY_URL = url;
2869
2906
  const specs = await loadSpecs(entry);
2870
- const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2907
+ const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2871
2908
  const reports = await verifyAll(url, specs, { toolDir });
2872
2909
  for (const report of reports) printReport(report);
2873
2910
  return allPass(reports);
@@ -2878,7 +2915,7 @@ async function previewViaDescriptor(entry, name, portOverride) {
2878
2915
  const port = portOverride ?? pv.port ?? entry.port ?? lane.port;
2879
2916
  const path = pv.path ?? lane.path;
2880
2917
  const url = `http://localhost:${port}${path}`;
2881
- const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2918
+ const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
2882
2919
  console.log(`preview ${name}: ${pv.command} (\u2192 ${url})`);
2883
2920
  const child = spawn(pv.command, {
2884
2921
  cwd: toolDir,
@@ -3191,7 +3228,6 @@ var HELP = `greenlight <command>
3191
3228
  promote <name> [--perform] [--push] gated develop -> main fast-forward
3192
3229
  status <name> last ship/deploy/verify run for a tool (via gh)
3193
3230
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
3194
- secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
3195
3231
  agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
3196
3232
  adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
3197
3233
  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.0",
3
+ "version": "0.4.0",
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-loop": "0.4.0",
35
+ "@rtrentjones/greenlight-adapters": "0.4.0",
36
+ "@rtrentjones/greenlight-verify": "0.4.0",
37
+ "@rtrentjones/greenlight-shared": "0.4.0"
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