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