@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.
- package/assets/skills/provider-cloudflare/SKILL.md +4 -3
- package/assets/skills/provider-gemini/SKILL.md +10 -2
- package/assets/skills/provider-github/SKILL.md +6 -5
- package/assets/skills/provider-supabase/SKILL.md +2 -1
- package/assets/skills/provider-vercel/SKILL.md +2 -1
- package/dist/bin.js +328 -292
- package/package.json +5 -5
- package/templates/_template-agent/wrangler.toml +7 -5
|
@@ -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.
|
|
23
|
-
|
|
24
|
-
verifies it against `/user/tokens/verify` (status must
|
|
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 —
|
|
32
|
+
## Deploy — emitted CI (push to main)
|
|
33
33
|
|
|
34
|
-
|
|
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 —
|
|
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
|
-
##
|
|
35
|
+
## Setting secrets
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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).
|
|
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).
|
|
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.
|
|
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
|
|
925
|
-
import { resolve as
|
|
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(
|
|
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 &&
|
|
962
|
-
map.set("TF_VAR_OCI_PRIVATE_KEY",
|
|
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((
|
|
980
|
+
ask: (query) => new Promise((resolve10) => {
|
|
1028
981
|
process.stdout.write(query);
|
|
1029
982
|
rl.question("", (val) => {
|
|
1030
983
|
process.stdout.write("\n");
|
|
1031
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
1239
|
+
process.exit(args[0] ? 1 : 0);
|
|
1150
1240
|
}
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
console.log(
|
|
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
|
-
|
|
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
|
|
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/ #
|
|
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 (
|
|
2580
|
+
if (existsSync8(path)) {
|
|
2548
2581
|
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
2549
2582
|
return;
|
|
2550
2583
|
}
|
|
2551
|
-
|
|
2552
|
-
|
|
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 =
|
|
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 =
|
|
2574
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2586
|
-
scaffoldIfAbsent(
|
|
2587
|
-
scaffoldIfAbsent(
|
|
2588
|
-
scaffoldIfAbsent(
|
|
2589
|
-
const
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
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 (
|
|
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
|
|
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) =>
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
"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-
|
|
35
|
-
"@rtrentjones/greenlight-
|
|
36
|
-
"@rtrentjones/greenlight-verify": "0.
|
|
37
|
-
"@rtrentjones/greenlight-shared": "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`
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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
|
|