@rtrentjones/greenlight 0.2.29 → 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.
- package/assets/skills/provider-gemini/SKILL.md +78 -0
- package/dist/bin.js +396 -241
- package/dist/{chunk-HMU7D7R2.js → chunk-P6FRYOOV.js} +3 -2
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/templates/_template-agent/README.md +33 -0
- package/templates/_template-agent/gitignore +5 -0
- package/templates/_template-agent/package.json +14 -0
- package/templates/_template-agent/src/index.ts +99 -0
- package/templates/_template-agent/tsconfig.json +13 -0
- package/templates/_template-agent/verify.config.ts +8 -0
- package/templates/_template-agent/wrangler.toml +49 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: provider-gemini
|
|
3
|
+
description: How the `agent` lane works in Greenlight — an autonomous cron-triggered Cloudflare Worker backed by Google Gemini (free tier). Covers the GEMINI_API_KEY (Google AI Studio, no billing), the gemini-2.5-flash generateContent call, the wrangler deploy (cron + KV + secret + custom_domain), the /, /status, /run surface, api-mode verify, and the free-tier safety envelope. Use when building, deploying, or verifying an agent tool.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# provider-gemini
|
|
7
|
+
|
|
8
|
+
The `agent` lane is an **autonomous tool**: a Cloudflare Worker that wakes on a **cron trigger**,
|
|
9
|
+
calls **Gemini** (Google's LLM, free tier), does low-stakes work, stores the result in KV, and
|
|
10
|
+
exposes a tiny HTTP surface. It's the keepalive Worker pattern promoted to a user tool — free,
|
|
11
|
+
always-available, immune to repo-inactivity, no OCI box, no new paid account.
|
|
12
|
+
|
|
13
|
+
`agent` → target **workers**, data **none | kv** (kv holds the last output + run metadata).
|
|
14
|
+
|
|
15
|
+
## Token — `GEMINI_API_KEY`
|
|
16
|
+
|
|
17
|
+
Create it at **Google AI Studio** (https://aistudio.google.com/apikey) — **free tier, no billing,
|
|
18
|
+
no card**. `greenlight add` verifies it against `…/v1beta/models?key=…` (HTTP 200). One key serves
|
|
19
|
+
every agent (shared, not per-tool). It is a **Cloudflare Worker secret** (`wrangler secret put
|
|
20
|
+
GEMINI_API_KEY`) — never in the repo.
|
|
21
|
+
|
|
22
|
+
## The model + call
|
|
23
|
+
|
|
24
|
+
`gemini-2.5-flash` (fast; generous free limits — ~15 RPM / 1500 req/day, so a daily cron is ~1/day).
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}
|
|
28
|
+
{ "contents": [{ "parts": [{ "text": "<prompt>" }] }] }
|
|
29
|
+
→ candidates[0].content.parts[0].text
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Deploy — emitted CI (push to main)
|
|
33
|
+
|
|
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.
|
|
43
|
+
|
|
44
|
+
```toml
|
|
45
|
+
[triggers]
|
|
46
|
+
crons = ["0 13 * * *"] # daily; stays far under the free-tier quota
|
|
47
|
+
[[kv_namespaces]]
|
|
48
|
+
binding = "STATE"
|
|
49
|
+
[[routes]]
|
|
50
|
+
pattern = "<name>.<domain>"
|
|
51
|
+
custom_domain = true
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Surface
|
|
55
|
+
|
|
56
|
+
| route | purpose |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `scheduled()` | the cron: prompt Gemini → `STATE.put(today, text + metadata)` |
|
|
59
|
+
| `GET /` | the latest output (public, read-only) |
|
|
60
|
+
| `GET /status` | `{ ok, lastRun, model, preview }` — the **api-mode verify** target |
|
|
61
|
+
| `POST /run` | force a run — **bearer-gated** (a `RUN_TOKEN` secret) so randoms can't burn the Gemini quota; lets deploy/verify seed the first output |
|
|
62
|
+
|
|
63
|
+
## Verify — `api` mode on `/status`
|
|
64
|
+
|
|
65
|
+
`verify.config.ts` hits `/status` and asserts `ok: true` + a recent run. (Output *quality* is a
|
|
66
|
+
future `eval` mode — LLM-judged.) Because the first cron may not have fired at deploy time, the
|
|
67
|
+
deploy step `POST /run`s once to seed, then verifies.
|
|
68
|
+
|
|
69
|
+
## Safety envelope
|
|
70
|
+
|
|
71
|
+
- **Low-stakes / read-only** first agents (generate → store → serve; no destructive external actions).
|
|
72
|
+
- **Bearer on `/run`**; the cron frequency stays far under the free-tier daily limit.
|
|
73
|
+
- Key is **secret-only** (a Worker secret), never committed or echoed.
|
|
74
|
+
|
|
75
|
+
## No keepalive
|
|
76
|
+
|
|
77
|
+
An agent needs no keepalive — the cron *is* its heartbeat and the Worker is always-available
|
|
78
|
+
(Cloudflare's edge, not a reclaimable box). Don't add it to `module.keepalive.targets_json`.
|
package/dist/bin.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
resolveUrl,
|
|
7
7
|
scanSqlFiles,
|
|
8
8
|
verifyAll
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-P6FRYOOV.js";
|
|
10
10
|
import "./chunk-HX7VA25D.js";
|
|
11
11
|
import "./chunk-N3IKUCSF.js";
|
|
12
12
|
import "./chunk-KP3Y6WRU.js";
|
|
@@ -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";
|
|
@@ -351,6 +440,29 @@ var PACKS = [
|
|
|
351
440
|
skill: "provider-neon",
|
|
352
441
|
tfModules: ["neon"]
|
|
353
442
|
},
|
|
443
|
+
{
|
|
444
|
+
id: "gemini",
|
|
445
|
+
name: "Google Gemini (free tier)",
|
|
446
|
+
// The LLM behind the `agent` lane. (A future `llm` axis would generalize this beyond agents.)
|
|
447
|
+
appliesTo: (t) => t.lane === "agent",
|
|
448
|
+
guide: "docs/provider-tokens.md \u2014 GEMINI_API_KEY (Google AI Studio, free tier, no billing)",
|
|
449
|
+
setupUrl: "https://aistudio.google.com/apikey",
|
|
450
|
+
tokens: [
|
|
451
|
+
{
|
|
452
|
+
// The agent Worker's LLM credential. Free tier (no billing / no card); set as a Cloudflare
|
|
453
|
+
// Worker secret, never in the repo. One key serves every agent (shared, not per-tool).
|
|
454
|
+
envVar: "GEMINI_API_KEY",
|
|
455
|
+
label: "Google AI Studio API key (Gemini free tier)",
|
|
456
|
+
verify: async (t) => {
|
|
457
|
+
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${t}`);
|
|
458
|
+
return { ok: okStatus(r), detail: `HTTP ${r.status}` };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
],
|
|
462
|
+
skill: "provider-gemini"
|
|
463
|
+
// No tfModules: the agent Worker (cron + KV + secret + custom_domain) deploys via wrangler,
|
|
464
|
+
// like the astro blog — KV/DNS are wrangler-managed for the workers target.
|
|
465
|
+
},
|
|
354
466
|
{
|
|
355
467
|
id: "hcp",
|
|
356
468
|
name: "HCP Terraform (remote state)",
|
|
@@ -478,7 +590,7 @@ function tokensForTool(tool) {
|
|
|
478
590
|
}
|
|
479
591
|
|
|
480
592
|
// src/version.ts
|
|
481
|
-
var MODULE_REF = "v0.
|
|
593
|
+
var MODULE_REF = "v0.3.1";
|
|
482
594
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
483
595
|
function moduleSource(module, ref = MODULE_REF) {
|
|
484
596
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -488,6 +600,14 @@ function moduleSource(module, ref = MODULE_REF) {
|
|
|
488
600
|
var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
489
601
|
function emitToolTf(opts) {
|
|
490
602
|
const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
|
|
603
|
+
if (lane === "agent") {
|
|
604
|
+
const suffix = data && data !== "none" ? `/${data}` : "";
|
|
605
|
+
return `# ${name} \u2014 agent/${target}${suffix}, emitted by \`greenlight add\`.
|
|
606
|
+
# Wrangler-managed: the Worker (cron + KV + custom_domain route + GEMINI_API_KEY/RUN_TOKEN
|
|
607
|
+
# secrets) deploys via \`wrangler deploy\` from tools/${name}/. No Terraform here \u2014 see that
|
|
608
|
+
# wrangler.toml + the provider-gemini skill + docs/agents-plan.md.
|
|
609
|
+
`;
|
|
610
|
+
}
|
|
491
611
|
const port = opts.port ?? 8e3;
|
|
492
612
|
const slug = opts.slug ?? `OWNER/${name}`;
|
|
493
613
|
const useSupabase = data === "supabase";
|
|
@@ -795,103 +915,15 @@ function providersForTool(tool) {
|
|
|
795
915
|
return out;
|
|
796
916
|
}
|
|
797
917
|
|
|
798
|
-
// src/
|
|
799
|
-
import {
|
|
800
|
-
import { resolve as
|
|
801
|
-
|
|
802
|
-
// src/agent-kit.ts
|
|
803
|
-
function recommendedMcp(tool) {
|
|
804
|
-
return mcpForTool(tool);
|
|
805
|
-
}
|
|
806
|
-
function mergeMcpServers(existing, add) {
|
|
807
|
-
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
808
|
-
for (const [name, val] of Object.entries(add)) {
|
|
809
|
-
if (out.mcpServers[name]) continue;
|
|
810
|
-
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
811
|
-
}
|
|
812
|
-
return out;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// src/commands/agent.ts
|
|
816
|
-
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
817
|
-
|
|
818
|
-
This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
|
|
819
|
-
tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
|
|
820
|
-
branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
|
|
821
|
-
push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
|
|
822
|
-
Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
|
|
823
|
-
pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
|
|
824
|
-
|
|
825
|
-
Agentic kit:
|
|
826
|
-
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
|
|
827
|
-
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
828
|
-
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
829
|
-
- Best-practice skills (one-time, user scope):
|
|
830
|
-
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
831
|
-
`;
|
|
832
|
-
function materializeAgentKit(dir, tool) {
|
|
833
|
-
const src = skillAssetDir();
|
|
834
|
-
if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
|
|
835
|
-
const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
|
|
836
|
-
mkdirSync(dest, { recursive: true });
|
|
837
|
-
cpSync(src, dest, { recursive: true });
|
|
838
|
-
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
839
|
-
for (const pack of packsForTool(tool)) {
|
|
840
|
-
if (!pack.skill) continue;
|
|
841
|
-
const skillSrc = skillAssetDir(pack.skill);
|
|
842
|
-
if (!existsSync3(skillSrc)) continue;
|
|
843
|
-
const skillDest = resolve3(dir, ".claude/skills", pack.skill);
|
|
844
|
-
mkdirSync(skillDest, { recursive: true });
|
|
845
|
-
cpSync(skillSrc, skillDest, { recursive: true });
|
|
846
|
-
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
847
|
-
}
|
|
848
|
-
const mcpPath = resolve3(dir, ".mcp.json");
|
|
849
|
-
const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
|
|
850
|
-
const servers = recommendedMcp(tool);
|
|
851
|
-
writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
852
|
-
`);
|
|
853
|
-
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
854
|
-
const claudePath = resolve3(dir, "CLAUDE.md");
|
|
855
|
-
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
856
|
-
const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
|
|
857
|
-
if (existing.includes(marker)) {
|
|
858
|
-
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
859
|
-
} else {
|
|
860
|
-
writeFileSync(claudePath, existing ? `${existing.trimEnd()}
|
|
861
|
-
|
|
862
|
-
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
863
|
-
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
async function agentCommand(args) {
|
|
867
|
-
if (args[0] !== "sync") {
|
|
868
|
-
console.log(
|
|
869
|
-
"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"
|
|
870
|
-
);
|
|
871
|
-
process.exit(args[0] ? 1 : 0);
|
|
872
|
-
}
|
|
873
|
-
const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
|
|
874
|
-
if (name) {
|
|
875
|
-
const { config } = await loadManifest();
|
|
876
|
-
const entry = resolveEntry(config, name);
|
|
877
|
-
const dir = resolve3(process.cwd(), entry.dir ?? ".");
|
|
878
|
-
materializeAgentKit(dir, { target: entry.target, data: entry.data });
|
|
879
|
-
console.log(
|
|
880
|
-
`
|
|
881
|
-
Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (target=${entry.target}, data=${entry.data}).`
|
|
882
|
-
);
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
materializeAgentKit(process.cwd());
|
|
886
|
-
console.log(
|
|
887
|
-
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
888
|
-
);
|
|
889
|
-
}
|
|
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";
|
|
890
922
|
|
|
891
923
|
// src/commands/secrets.ts
|
|
892
924
|
import { execFileSync } from "child_process";
|
|
893
|
-
import { existsSync as
|
|
894
|
-
import { resolve as
|
|
925
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
926
|
+
import { resolve as resolve3 } from "path";
|
|
895
927
|
import { createInterface } from "readline";
|
|
896
928
|
function parseSecretsEnv(text) {
|
|
897
929
|
const out = [];
|
|
@@ -917,7 +949,7 @@ function parseOciConfig(text) {
|
|
|
917
949
|
return out;
|
|
918
950
|
}
|
|
919
951
|
function ociPrefill(configPath, keyPath) {
|
|
920
|
-
const cfg = parseOciConfig(
|
|
952
|
+
const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
|
|
921
953
|
const map = /* @__PURE__ */ new Map();
|
|
922
954
|
const set = (k, v) => {
|
|
923
955
|
if (v) map.set(k, v);
|
|
@@ -927,8 +959,8 @@ function ociPrefill(configPath, keyPath) {
|
|
|
927
959
|
set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
|
|
928
960
|
set("TF_VAR_OCI_REGION", cfg.region);
|
|
929
961
|
const pem = keyPath ?? cfg.key_file;
|
|
930
|
-
if (pem &&
|
|
931
|
-
map.set("TF_VAR_OCI_PRIVATE_KEY",
|
|
962
|
+
if (pem && existsSync3(pem)) {
|
|
963
|
+
map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
|
|
932
964
|
} else if (pem) {
|
|
933
965
|
console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
|
|
934
966
|
}
|
|
@@ -962,11 +994,11 @@ function syncSecrets(opts) {
|
|
|
962
994
|
"could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
|
|
963
995
|
);
|
|
964
996
|
}
|
|
965
|
-
const path =
|
|
966
|
-
if (!
|
|
997
|
+
const path = resolve3(opts.cwd, ".greenlight/secrets.env");
|
|
998
|
+
if (!existsSync3(path)) {
|
|
967
999
|
throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
|
|
968
1000
|
}
|
|
969
|
-
const entries = parseSecretsEnv(
|
|
1001
|
+
const entries = parseSecretsEnv(readFileSync(path, "utf8"));
|
|
970
1002
|
const target = opts.env ? `env "${opts.env}"` : "repo";
|
|
971
1003
|
for (const { key, value } of entries) {
|
|
972
1004
|
const ghArgs = ["secret", "set", key, "--repo", repo];
|
|
@@ -1035,7 +1067,7 @@ function setGitHubSecret(repo, env, key, value) {
|
|
|
1035
1067
|
async function gatherSecrets(name, repo, env, prefill) {
|
|
1036
1068
|
const { config } = await loadManifest();
|
|
1037
1069
|
const entry = resolveEntry(config, name);
|
|
1038
|
-
const packs = packsForTool({ target: entry.target, data: entry.data });
|
|
1070
|
+
const packs = packsForTool({ lane: entry.lane, target: entry.target, data: entry.data });
|
|
1039
1071
|
const dest = env ? `env "${env}" of ${repo}` : repo;
|
|
1040
1072
|
const existing = listGitHubSecrets(repo, env);
|
|
1041
1073
|
console.log(`Gathering secrets for "${name}" \u2192 GitHub ${dest}`);
|
|
@@ -1107,7 +1139,7 @@ async function secretsCommand(args) {
|
|
|
1107
1139
|
if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
|
|
1108
1140
|
const ociConfig2 = flag(args, "--oci-config");
|
|
1109
1141
|
const ociKey = flag(args, "--oci-key");
|
|
1110
|
-
const prefill = ociConfig2 ? ociPrefill(
|
|
1142
|
+
const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
|
|
1111
1143
|
await gatherSecrets(name, repo, flag(args, "--env"), prefill);
|
|
1112
1144
|
return;
|
|
1113
1145
|
}
|
|
@@ -1132,6 +1164,177 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
|
|
|
1132
1164
|
);
|
|
1133
1165
|
}
|
|
1134
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
|
+
|
|
1135
1338
|
// src/commands/add.ts
|
|
1136
1339
|
function flag2(args, name) {
|
|
1137
1340
|
const i = args.indexOf(name);
|
|
@@ -1143,25 +1346,25 @@ function templateDir(lane, target) {
|
|
|
1143
1346
|
}
|
|
1144
1347
|
function registerWorkspaceMember(cwd, member) {
|
|
1145
1348
|
const wsPath = join(cwd, "pnpm-workspace.yaml");
|
|
1146
|
-
if (!
|
|
1147
|
-
|
|
1349
|
+
if (!existsSync6(wsPath)) {
|
|
1350
|
+
writeFileSync3(wsPath, `packages:
|
|
1148
1351
|
- "${member}"
|
|
1149
1352
|
`);
|
|
1150
1353
|
console.log(`\u2714 created pnpm-workspace.yaml (member ${member})`);
|
|
1151
1354
|
return;
|
|
1152
1355
|
}
|
|
1153
|
-
const text =
|
|
1356
|
+
const text = readFileSync4(wsPath, "utf8");
|
|
1154
1357
|
if (text.includes(member) || /^\s*-\s*["']?tools\/\*/m.test(text)) return;
|
|
1155
1358
|
const lines = text.split("\n");
|
|
1156
1359
|
const pkgIdx = lines.findIndex((l) => /^packages\s*:/.test(l));
|
|
1157
1360
|
if (pkgIdx === -1) {
|
|
1158
|
-
|
|
1361
|
+
writeFileSync3(wsPath, `${text.replace(/\s*$/, "")}
|
|
1159
1362
|
packages:
|
|
1160
1363
|
- "${member}"
|
|
1161
1364
|
`);
|
|
1162
1365
|
} else {
|
|
1163
1366
|
lines.splice(pkgIdx + 1, 0, ` - "${member}"`);
|
|
1164
|
-
|
|
1367
|
+
writeFileSync3(wsPath, lines.join("\n"));
|
|
1165
1368
|
}
|
|
1166
1369
|
console.log(`\u2714 registered ${member} in pnpm-workspace.yaml`);
|
|
1167
1370
|
}
|
|
@@ -1194,44 +1397,61 @@ async function addCommand(args) {
|
|
|
1194
1397
|
const entry = next.tools.find((t) => t.name === name);
|
|
1195
1398
|
const data = entry?.data ?? "none";
|
|
1196
1399
|
const envs = entry?.envs ?? ["beta", "prod"];
|
|
1197
|
-
const toolInfo = { target, data };
|
|
1198
|
-
const dest =
|
|
1199
|
-
if (
|
|
1400
|
+
const toolInfo = { lane, target, data };
|
|
1401
|
+
const dest = resolve6(process.cwd(), "tools", name);
|
|
1402
|
+
if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
|
|
1200
1403
|
const src = templateDir(lane, target);
|
|
1201
|
-
if (
|
|
1404
|
+
if (existsSync6(src)) {
|
|
1202
1405
|
cpSync2(src, dest, { recursive: true });
|
|
1203
1406
|
const pkgPath = join(dest, "package.json");
|
|
1204
|
-
if (
|
|
1205
|
-
const pkg = JSON.parse(
|
|
1407
|
+
if (existsSync6(pkgPath)) {
|
|
1408
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1206
1409
|
pkg.name = name;
|
|
1207
|
-
|
|
1410
|
+
writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1208
1411
|
`);
|
|
1209
1412
|
}
|
|
1210
1413
|
const shippedGitignore = join(dest, "gitignore");
|
|
1211
|
-
if (
|
|
1414
|
+
if (existsSync6(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
|
|
1415
|
+
const wranglerPath = join(dest, "wrangler.toml");
|
|
1416
|
+
if (existsSync6(wranglerPath)) {
|
|
1417
|
+
let wt = readFileSync4(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
|
|
1418
|
+
if (wt.includes("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID")) {
|
|
1419
|
+
const token = presentEnv(process.cwd()).CLOUDFLARE_API_TOKEN;
|
|
1420
|
+
const acct = token ? await resolveCloudflareAccountId(config.domain, token) : null;
|
|
1421
|
+
if (acct) {
|
|
1422
|
+
wt = wt.replaceAll("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID", acct);
|
|
1423
|
+
console.log("\u2714 resolved the Cloudflare account id into wrangler.toml");
|
|
1424
|
+
} else {
|
|
1425
|
+
console.log(
|
|
1426
|
+
"\xB7 could not resolve the Cloudflare account id \u2014 set account_id in wrangler.toml"
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
writeFileSync3(wranglerPath, wt);
|
|
1431
|
+
}
|
|
1212
1432
|
console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
|
|
1213
|
-
if (
|
|
1433
|
+
if (existsSync6(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
|
|
1214
1434
|
} else {
|
|
1215
1435
|
console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
|
|
1216
1436
|
}
|
|
1217
|
-
|
|
1437
|
+
writeFileSync3(path, serializeConfig(next));
|
|
1218
1438
|
console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
|
|
1219
1439
|
const cwd = process.cwd();
|
|
1220
1440
|
const providers = providersForTool(toolInfo);
|
|
1221
|
-
const infraDir =
|
|
1441
|
+
const infraDir = resolve6(cwd, "infra");
|
|
1222
1442
|
const mainTf = join(infraDir, "main.tf");
|
|
1223
|
-
if (!
|
|
1224
|
-
|
|
1225
|
-
|
|
1443
|
+
if (!existsSync6(mainTf)) {
|
|
1444
|
+
mkdirSync3(infraDir, { recursive: true });
|
|
1445
|
+
writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
|
|
1226
1446
|
console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
|
|
1227
1447
|
} else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1228
1448
|
console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
|
|
1229
1449
|
}
|
|
1230
1450
|
const toolTf = join(infraDir, `${name}.tf`);
|
|
1231
|
-
if (
|
|
1451
|
+
if (existsSync6(toolTf)) {
|
|
1232
1452
|
console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
|
|
1233
1453
|
} else {
|
|
1234
|
-
|
|
1454
|
+
writeFileSync3(
|
|
1235
1455
|
toolTf,
|
|
1236
1456
|
emitToolTf({
|
|
1237
1457
|
name,
|
|
@@ -1247,6 +1467,17 @@ async function addCommand(args) {
|
|
|
1247
1467
|
);
|
|
1248
1468
|
console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
|
|
1249
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
|
+
}
|
|
1250
1481
|
materializeAgentKit(cwd, toolInfo);
|
|
1251
1482
|
const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
|
|
1252
1483
|
const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
|
|
@@ -1269,8 +1500,8 @@ Next:${gather ? "" : `
|
|
|
1269
1500
|
|
|
1270
1501
|
// src/commands/adopt.ts
|
|
1271
1502
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
1272
|
-
import { cpSync as cpSync3, existsSync as
|
|
1273
|
-
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";
|
|
1274
1505
|
var REF = MODULE_REF;
|
|
1275
1506
|
function flag3(args, name) {
|
|
1276
1507
|
const i = args.indexOf(name);
|
|
@@ -1286,7 +1517,7 @@ function mergePackageJson(existing, repoName, vendor) {
|
|
|
1286
1517
|
}
|
|
1287
1518
|
function vendorDeps(vendorDir) {
|
|
1288
1519
|
const out = {};
|
|
1289
|
-
if (!
|
|
1520
|
+
if (!existsSync7(vendorDir)) return out;
|
|
1290
1521
|
for (const f of readdirSync(vendorDir)) {
|
|
1291
1522
|
if (!f.endsWith(".tgz")) continue;
|
|
1292
1523
|
const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
|
|
@@ -1741,12 +1972,12 @@ export default [api, ...agentWeb];
|
|
|
1741
1972
|
`;
|
|
1742
1973
|
}
|
|
1743
1974
|
function writeIfAbsent(path, contents, label) {
|
|
1744
|
-
if (
|
|
1975
|
+
if (existsSync7(path)) {
|
|
1745
1976
|
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
1746
1977
|
return;
|
|
1747
1978
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
1979
|
+
mkdirSync4(resolve7(path, ".."), { recursive: true });
|
|
1980
|
+
writeFileSync4(path, contents);
|
|
1750
1981
|
console.log(`\u2714 ${label}`);
|
|
1751
1982
|
}
|
|
1752
1983
|
async function adoptCommand(args) {
|
|
@@ -1782,10 +2013,10 @@ async function adoptWrapper(ctx) {
|
|
|
1782
2013
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1783
2014
|
const cwd = process.cwd();
|
|
1784
2015
|
const toolRel = `tools/${name}`;
|
|
1785
|
-
const dest =
|
|
2016
|
+
const dest = resolve7(cwd, toolRel);
|
|
1786
2017
|
console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
|
|
1787
2018
|
`);
|
|
1788
|
-
if (!
|
|
2019
|
+
if (!existsSync7(dest)) {
|
|
1789
2020
|
try {
|
|
1790
2021
|
execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
|
|
1791
2022
|
console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
|
|
@@ -1821,7 +2052,7 @@ async function adoptWrapper(ctx) {
|
|
|
1821
2052
|
}
|
|
1822
2053
|
} : {}
|
|
1823
2054
|
});
|
|
1824
|
-
|
|
2055
|
+
writeFileSync4(regPath, serializeConfig(nextReg));
|
|
1825
2056
|
console.log(
|
|
1826
2057
|
`\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
|
|
1827
2058
|
);
|
|
@@ -1838,11 +2069,11 @@ async function adoptWrapper(ctx) {
|
|
|
1838
2069
|
`verify/${name}.config.ts`
|
|
1839
2070
|
);
|
|
1840
2071
|
}
|
|
1841
|
-
const providers = providersForTool({ target, data });
|
|
1842
|
-
if (
|
|
2072
|
+
const providers = providersForTool({ lane, target, data });
|
|
2073
|
+
if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1843
2074
|
console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
|
|
1844
2075
|
}
|
|
1845
|
-
materializeAgentKit(dest, { target, data });
|
|
2076
|
+
materializeAgentKit(dest, { lane, target, data });
|
|
1846
2077
|
addGreenlightScript(dest);
|
|
1847
2078
|
if (target === "oci") {
|
|
1848
2079
|
const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
|
|
@@ -1891,9 +2122,9 @@ Next:
|
|
|
1891
2122
|
}
|
|
1892
2123
|
async function adoptStandalone(ctx) {
|
|
1893
2124
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1894
|
-
const repo =
|
|
1895
|
-
if (!
|
|
1896
|
-
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");
|
|
1897
2128
|
const vendor = vendorDeps(regVendor);
|
|
1898
2129
|
if (Object.keys(vendor).length === 0) {
|
|
1899
2130
|
throw new Error(
|
|
@@ -1911,15 +2142,15 @@ async function adoptStandalone(ctx) {
|
|
|
1911
2142
|
);
|
|
1912
2143
|
const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1913
2144
|
const pkgPath = join2(repo, "package.json");
|
|
1914
|
-
const existingPkg =
|
|
1915
|
-
|
|
2145
|
+
const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
|
|
2146
|
+
writeFileSync4(
|
|
1916
2147
|
pkgPath,
|
|
1917
2148
|
`${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
|
|
1918
2149
|
`
|
|
1919
2150
|
);
|
|
1920
2151
|
console.log("\u2714 package.json (merged framework deps + overrides)");
|
|
1921
2152
|
const repoVendor = join2(repo, "vendor");
|
|
1922
|
-
|
|
2153
|
+
mkdirSync4(repoVendor, { recursive: true });
|
|
1923
2154
|
for (const f of readdirSync(regVendor)) {
|
|
1924
2155
|
if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
|
|
1925
2156
|
}
|
|
@@ -1940,7 +2171,7 @@ async function adoptStandalone(ctx) {
|
|
|
1940
2171
|
".github/workflows/greenlight-promote.yml"
|
|
1941
2172
|
);
|
|
1942
2173
|
writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
|
|
1943
|
-
materializeAgentKit(repo, { target, data });
|
|
2174
|
+
materializeAgentKit(repo, { lane, target, data });
|
|
1944
2175
|
writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
|
|
1945
2176
|
writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
|
|
1946
2177
|
const nextReg = addTool(reg, {
|
|
@@ -1953,7 +2184,7 @@ async function adoptStandalone(ctx) {
|
|
|
1953
2184
|
external: true,
|
|
1954
2185
|
adopted: true
|
|
1955
2186
|
});
|
|
1956
|
-
|
|
2187
|
+
writeFileSync4(regPath, serializeConfig(nextReg));
|
|
1957
2188
|
console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
|
|
1958
2189
|
console.log(`
|
|
1959
2190
|
Next (in the adopted repo):
|
|
@@ -1966,20 +2197,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
|
|
|
1966
2197
|
}
|
|
1967
2198
|
function addGreenlightScript(dir) {
|
|
1968
2199
|
const pkgPath = join2(dir, "package.json");
|
|
1969
|
-
if (!
|
|
2200
|
+
if (!existsSync7(pkgPath)) {
|
|
1970
2201
|
console.log(
|
|
1971
2202
|
"\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
|
|
1972
2203
|
);
|
|
1973
2204
|
return;
|
|
1974
2205
|
}
|
|
1975
|
-
const pkg = JSON.parse(
|
|
2206
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
1976
2207
|
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
1977
2208
|
if (pkg.scripts.greenlight) {
|
|
1978
2209
|
console.log("\xB7 package.json already has a greenlight script");
|
|
1979
2210
|
return;
|
|
1980
2211
|
}
|
|
1981
2212
|
pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
|
|
1982
|
-
|
|
2213
|
+
writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1983
2214
|
`);
|
|
1984
2215
|
console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
|
|
1985
2216
|
}
|
|
@@ -2124,10 +2355,10 @@ async function deployCommand(args) {
|
|
|
2124
2355
|
// src/commands/doctor.ts
|
|
2125
2356
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
2126
2357
|
import { lookup } from "dns/promises";
|
|
2127
|
-
import { existsSync as
|
|
2358
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
|
|
2128
2359
|
import { join as join4 } from "path";
|
|
2129
2360
|
function dirCheck(label, dir) {
|
|
2130
|
-
return
|
|
2361
|
+
return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
|
|
2131
2362
|
}
|
|
2132
2363
|
function conformanceChecks(t, root) {
|
|
2133
2364
|
const out = [];
|
|
@@ -2137,7 +2368,7 @@ function conformanceChecks(t, root) {
|
|
|
2137
2368
|
join4(toolDir, `verify/${t.name}.config.ts`),
|
|
2138
2369
|
join4(toolDir, "verify.config.ts")
|
|
2139
2370
|
] : [join4(toolDir, "verify.config.ts")];
|
|
2140
|
-
const found = specCandidates.find((p) =>
|
|
2371
|
+
const found = specCandidates.find((p) => existsSync8(join4(root, p)));
|
|
2141
2372
|
out.push({
|
|
2142
2373
|
name: `${t.name}: in the verify loop`,
|
|
2143
2374
|
status: found ? "ok" : "warn",
|
|
@@ -2163,14 +2394,14 @@ function conformanceChecks(t, root) {
|
|
|
2163
2394
|
}
|
|
2164
2395
|
if (!t.external && t.lane === "next" && t.target === "vercel") {
|
|
2165
2396
|
const wsPath = join4(root, "pnpm-workspace.yaml");
|
|
2166
|
-
const ws =
|
|
2397
|
+
const ws = existsSync8(wsPath) ? readFileSync6(wsPath, "utf8") : "";
|
|
2167
2398
|
const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
|
|
2168
2399
|
out.push({
|
|
2169
2400
|
name: `${t.name}: pnpm workspace member`,
|
|
2170
2401
|
status: member ? "ok" : "warn",
|
|
2171
2402
|
detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
|
|
2172
2403
|
});
|
|
2173
|
-
const hasVercelJson =
|
|
2404
|
+
const hasVercelJson = existsSync8(join4(root, toolDir, "vercel.json"));
|
|
2174
2405
|
out.push({
|
|
2175
2406
|
name: `${t.name}: vercel.json framework`,
|
|
2176
2407
|
status: hasVercelJson ? "ok" : "warn",
|
|
@@ -2184,7 +2415,7 @@ function versionDriftCheck(root) {
|
|
|
2184
2415
|
let installed;
|
|
2185
2416
|
try {
|
|
2186
2417
|
const pkg = JSON.parse(
|
|
2187
|
-
|
|
2418
|
+
readFileSync6(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
|
|
2188
2419
|
);
|
|
2189
2420
|
installed = pkg.version;
|
|
2190
2421
|
} catch {
|
|
@@ -2192,7 +2423,7 @@ function versionDriftCheck(root) {
|
|
|
2192
2423
|
const refs = /* @__PURE__ */ new Set();
|
|
2193
2424
|
try {
|
|
2194
2425
|
for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
|
|
2195
|
-
const body =
|
|
2426
|
+
const body = readFileSync6(join4(root, "infra", f), "utf8");
|
|
2196
2427
|
for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
|
|
2197
2428
|
if (m[1]) refs.add(m[1]);
|
|
2198
2429
|
}
|
|
@@ -2248,7 +2479,7 @@ function runDoctor(config, root) {
|
|
|
2248
2479
|
checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
|
|
2249
2480
|
if (t.dir) {
|
|
2250
2481
|
checks.push(
|
|
2251
|
-
|
|
2482
|
+
existsSync8(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
|
|
2252
2483
|
name: `${t.name}: dir present`,
|
|
2253
2484
|
status: "warn",
|
|
2254
2485
|
detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
|
|
@@ -2342,89 +2573,6 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
|
2342
2573
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
2343
2574
|
import { resolve as resolve8 } from "path";
|
|
2344
2575
|
import { createInterface as createInterface3 } from "readline/promises";
|
|
2345
|
-
|
|
2346
|
-
// src/tokens.ts
|
|
2347
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
2348
|
-
import { resolve as resolve7 } from "path";
|
|
2349
|
-
import { createInterface as createInterface2 } from "readline/promises";
|
|
2350
|
-
var SECRETS_DIR = ".greenlight";
|
|
2351
|
-
var SECRETS_FILE = "secrets.env";
|
|
2352
|
-
function presentEnv(cwd) {
|
|
2353
|
-
const out = {};
|
|
2354
|
-
const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
|
|
2355
|
-
if (existsSync8(p)) {
|
|
2356
|
-
for (const { key, value } of parseSecretsEnv(readFileSync6(p, "utf8"))) out[key] = value;
|
|
2357
|
-
}
|
|
2358
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
2359
|
-
if (v !== void 0 && !(k in out)) out[k] = v;
|
|
2360
|
-
}
|
|
2361
|
-
return out;
|
|
2362
|
-
}
|
|
2363
|
-
function upsertSecret(cwd, key, value) {
|
|
2364
|
-
const dir = resolve7(cwd, SECRETS_DIR);
|
|
2365
|
-
mkdirSync4(dir, { recursive: true });
|
|
2366
|
-
const p = resolve7(dir, SECRETS_FILE);
|
|
2367
|
-
const lines = existsSync8(p) ? readFileSync6(p, "utf8").split("\n") : [];
|
|
2368
|
-
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
2369
|
-
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
2370
|
-
else {
|
|
2371
|
-
while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
|
|
2372
|
-
lines.push(`${key}=${value}`);
|
|
2373
|
-
}
|
|
2374
|
-
writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
|
|
2375
|
-
`, { mode: 384 });
|
|
2376
|
-
}
|
|
2377
|
-
async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
2378
|
-
const doVerify = opts.verify !== false;
|
|
2379
|
-
const interactive = Boolean(process.stdin.isTTY);
|
|
2380
|
-
const env = presentEnv(cwd);
|
|
2381
|
-
const results = [];
|
|
2382
|
-
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
|
|
2383
|
-
try {
|
|
2384
|
-
for (const spec of tokensForTool(tool)) {
|
|
2385
|
-
let value = env[spec.envVar];
|
|
2386
|
-
if (value) {
|
|
2387
|
-
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
2388
|
-
} else if (rl) {
|
|
2389
|
-
console.log(`
|
|
2390
|
-
${spec.envVar} \u2014 ${spec.label}`);
|
|
2391
|
-
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
2392
|
-
const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
|
|
2393
|
-
if (!entered) {
|
|
2394
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
2395
|
-
continue;
|
|
2396
|
-
}
|
|
2397
|
-
upsertSecret(cwd, spec.envVar, entered);
|
|
2398
|
-
env[spec.envVar] = entered;
|
|
2399
|
-
value = entered;
|
|
2400
|
-
results.push({ envVar: spec.envVar, outcome: "entered" });
|
|
2401
|
-
} else {
|
|
2402
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
2403
|
-
continue;
|
|
2404
|
-
}
|
|
2405
|
-
if (value && doVerify && spec.verify) {
|
|
2406
|
-
let check;
|
|
2407
|
-
try {
|
|
2408
|
-
check = await spec.verify(value, env);
|
|
2409
|
-
} catch (e) {
|
|
2410
|
-
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
2411
|
-
}
|
|
2412
|
-
const last = results[results.length - 1];
|
|
2413
|
-
if (last) last.verify = check;
|
|
2414
|
-
if (!check.ok && !spec.optional) {
|
|
2415
|
-
throw new Error(
|
|
2416
|
-
`${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
2417
|
-
);
|
|
2418
|
-
}
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
} finally {
|
|
2422
|
-
rl?.close();
|
|
2423
|
-
}
|
|
2424
|
-
return results;
|
|
2425
|
-
}
|
|
2426
|
-
|
|
2427
|
-
// src/commands/init.ts
|
|
2428
2576
|
function flag5(args, name) {
|
|
2429
2577
|
const i = args.indexOf(name);
|
|
2430
2578
|
return i >= 0 ? args[i + 1] : void 0;
|
|
@@ -2674,6 +2822,13 @@ function defaultSpec(lane) {
|
|
|
2674
2822
|
return { mode: "api", checks: [{ path: "/", status: 200 }] };
|
|
2675
2823
|
case "mcp":
|
|
2676
2824
|
return { mode: "mcp", expectTools: [] };
|
|
2825
|
+
case "agent":
|
|
2826
|
+
return {
|
|
2827
|
+
mode: "api",
|
|
2828
|
+
checks: [{ path: "/status", status: 200 }],
|
|
2829
|
+
settleRetries: 6,
|
|
2830
|
+
settleMs: 5e3
|
|
2831
|
+
};
|
|
2677
2832
|
}
|
|
2678
2833
|
}
|
|
2679
2834
|
function printReport(report) {
|
|
@@ -13,7 +13,7 @@ import { createJiti } from "jiti";
|
|
|
13
13
|
|
|
14
14
|
// ../packages/shared/src/schema.ts
|
|
15
15
|
import { z } from "zod";
|
|
16
|
-
var LaneEnum = z.enum(["astro", "next", "mcp"]);
|
|
16
|
+
var LaneEnum = z.enum(["astro", "next", "mcp", "agent"]);
|
|
17
17
|
var TargetEnum = z.enum(["workers", "vercel", "oci"]);
|
|
18
18
|
var DataEnum = z.enum(["none", "d1", "kv", "supabase", "neon"]);
|
|
19
19
|
var AuthEnum = z.enum(["none", "bearer", "oauth"]);
|
|
@@ -22,7 +22,8 @@ var EnvEnum = z.enum(["preview", "beta", "prod"]);
|
|
|
22
22
|
var MATRIX = {
|
|
23
23
|
astro: { targets: ["workers"], data: ["none", "d1", "kv"] },
|
|
24
24
|
next: { targets: ["vercel"], data: ["none", "supabase", "neon"] },
|
|
25
|
-
mcp: { targets: ["workers", "oci"], data: ["none"] }
|
|
25
|
+
mcp: { targets: ["workers", "oci"], data: ["none"] },
|
|
26
|
+
agent: { targets: ["workers"], data: ["none", "kv"] }
|
|
26
27
|
};
|
|
27
28
|
var ToolSchema = z.object({
|
|
28
29
|
name: z.string().regex(/^[a-z][a-z0-9-]*$/, "tool name must be kebab-case starting with a letter"),
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Greenlight CLI — setup and lifecycle for the harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"@anthropic-ai/sdk": "^0.69.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@rtrentjones/greenlight-adapters": "0.
|
|
35
|
-
"@rtrentjones/greenlight-loop": "0.
|
|
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",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# agent — autonomous Gemini-backed Worker
|
|
2
|
+
|
|
3
|
+
A cron-triggered Cloudflare Worker that calls **Gemini** (free tier), stores the result in **KV**,
|
|
4
|
+
and serves it. Scaffolded by `greenlight add <name> --lane agent --target workers --data kv`.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
- `scheduled` (cron, daily) → prompt Gemini → store the text + metadata in KV.
|
|
9
|
+
- `GET /` — the latest output (plain text).
|
|
10
|
+
- `GET /status` — `{ ok, lastRun, model, preview }` (the verify target).
|
|
11
|
+
- `POST /run` — force a run; **bearer-gated** (`Authorization: Bearer $RUN_TOKEN`).
|
|
12
|
+
|
|
13
|
+
## Setup (once)
|
|
14
|
+
|
|
15
|
+
`greenlight add` already rewrote the worker `name` + route domain in `wrangler.toml`. Then:
|
|
16
|
+
|
|
17
|
+
1. **KV namespace** — `pnpm exec wrangler kv namespace create STATE`, and paste the id into the
|
|
18
|
+
three `id = "REPLACE_WITH_KV_NAMESPACE_ID"` slots in `wrangler.toml`.
|
|
19
|
+
2. **Secrets** (per env — the key never goes in the repo):
|
|
20
|
+
- `pnpm exec wrangler secret put GEMINI_API_KEY --env prod` — your Google AI Studio key (free,
|
|
21
|
+
`aistudio.google.com/apikey`).
|
|
22
|
+
- `pnpm exec wrangler secret put RUN_TOKEN --env prod` — any random string.
|
|
23
|
+
3. **Deploy → seed → verify**:
|
|
24
|
+
- `pnpm greenlight deploy <name>` (or `pnpm exec wrangler deploy --env prod`).
|
|
25
|
+
- `curl -XPOST https://<name>.<domain>/run -H "Authorization: Bearer $RUN_TOKEN"` (seed the first run).
|
|
26
|
+
- `pnpm greenlight verify <name> --env prod`.
|
|
27
|
+
|
|
28
|
+
## Free-tier safety
|
|
29
|
+
|
|
30
|
+
The daily cron is ~1 Gemini call/day (the free tier allows ~1500/day). `/run` is bearer-gated so it
|
|
31
|
+
can't be used to burn the quota. The key lives **only** as a Worker secret.
|
|
32
|
+
|
|
33
|
+
See the `provider-gemini` skill + [docs/agents-plan.md](../../docs/agents-plan.md).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "_template-agent",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "wrangler dev",
|
|
7
|
+
"deploy": "wrangler deploy"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@cloudflare/workers-types": "^4.20250601.0",
|
|
11
|
+
"typescript": "^5.6.0",
|
|
12
|
+
"wrangler": "^3.90.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Worker — autonomous, cron-triggered, Gemini-backed.
|
|
3
|
+
*
|
|
4
|
+
* On the cron (`scheduled`) it asks Gemini for content and stores it in KV. The `fetch` handler
|
|
5
|
+
* serves the latest output (`GET /`), reports last-run metadata (`GET /status` — the verify
|
|
6
|
+
* target), and force-runs (`POST /run`, bearer-gated so it can't be used to burn the free-tier
|
|
7
|
+
* quota; the deploy step uses it to seed the first output before verify).
|
|
8
|
+
*
|
|
9
|
+
* GEMINI_API_KEY + RUN_TOKEN are Worker secrets (`wrangler secret put …`), never in wrangler.toml.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Env {
|
|
13
|
+
STATE: KVNamespace;
|
|
14
|
+
GEMINI_API_KEY: string;
|
|
15
|
+
RUN_TOKEN?: string;
|
|
16
|
+
MODEL: string;
|
|
17
|
+
GREENLIGHT_ENV: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AgentRecord {
|
|
21
|
+
ok: true;
|
|
22
|
+
text: string;
|
|
23
|
+
lastRun: string;
|
|
24
|
+
model: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROMPT =
|
|
28
|
+
'In one or two sentences, share a single concrete, non-obvious tip about deploying software ' +
|
|
29
|
+
'safely. Vary the topic each time. Plain text, no preamble, no markdown.';
|
|
30
|
+
|
|
31
|
+
async function callGemini(env: Env): Promise<string> {
|
|
32
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${env.MODEL}:generateContent?key=${env.GEMINI_API_KEY}`;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: PROMPT }] }] }),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) throw new Error(`Gemini HTTP ${res.status}`);
|
|
39
|
+
const data = (await res.json()) as {
|
|
40
|
+
candidates?: { content?: { parts?: { text?: string }[] } }[];
|
|
41
|
+
};
|
|
42
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
43
|
+
if (!text) throw new Error('Gemini returned no text');
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function key(env: Env): string {
|
|
48
|
+
return `${env.GREENLIGHT_ENV}:latest`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runOnce(env: Env): Promise<AgentRecord> {
|
|
52
|
+
const rec: AgentRecord = {
|
|
53
|
+
ok: true,
|
|
54
|
+
text: await callGemini(env),
|
|
55
|
+
lastRun: new Date().toISOString(),
|
|
56
|
+
model: env.MODEL,
|
|
57
|
+
};
|
|
58
|
+
await env.STATE.put(key(env), JSON.stringify(rec));
|
|
59
|
+
return rec;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function latest(env: Env): Promise<AgentRecord | null> {
|
|
63
|
+
const raw = await env.STATE.get(key(env));
|
|
64
|
+
return raw ? (JSON.parse(raw) as AgentRecord) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
69
|
+
ctx.waitUntil(runOnce(env));
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async fetch(req: Request, env: Env): Promise<Response> {
|
|
73
|
+
const { pathname } = new URL(req.url);
|
|
74
|
+
|
|
75
|
+
if (req.method === 'POST' && pathname === '/run') {
|
|
76
|
+
const auth = req.headers.get('authorization');
|
|
77
|
+
if (!env.RUN_TOKEN || auth !== `Bearer ${env.RUN_TOKEN}`) {
|
|
78
|
+
return new Response('unauthorized', { status: 401 });
|
|
79
|
+
}
|
|
80
|
+
return Response.json(await runOnce(env));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (pathname === '/status') {
|
|
84
|
+
const rec = await latest(env);
|
|
85
|
+
return Response.json({
|
|
86
|
+
ok: Boolean(rec),
|
|
87
|
+
lastRun: rec?.lastRun ?? null,
|
|
88
|
+
model: env.MODEL,
|
|
89
|
+
preview: rec ? rec.text.slice(0, 80) : null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rec = await latest(env);
|
|
94
|
+
if (!rec) return new Response('No run yet — POST /run to seed.\n', { status: 200 });
|
|
95
|
+
return new Response(`${rec.text}\n`, {
|
|
96
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"module": "es2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["es2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The agent exposes GET /status with last-run metadata. The deploy step seeds a run (POST /run)
|
|
2
|
+
// before verify, so /status reports ok:true — assert that. settle absorbs Cloudflare propagation.
|
|
3
|
+
export default {
|
|
4
|
+
mode: 'api',
|
|
5
|
+
checks: [{ path: '/status', status: 200, contains: '"ok":true' }],
|
|
6
|
+
settleRetries: 6,
|
|
7
|
+
settleMs: 5000,
|
|
8
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
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.
|
|
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"
|
|
9
|
+
main = "src/index.ts"
|
|
10
|
+
compatibility_date = "2025-06-01"
|
|
11
|
+
|
|
12
|
+
# Daily 13:00 UTC — far under the Gemini free-tier quota (~1500 req/day). Edit to taste.
|
|
13
|
+
[triggers]
|
|
14
|
+
crons = ["0 13 * * *"]
|
|
15
|
+
|
|
16
|
+
[vars]
|
|
17
|
+
MODEL = "gemini-2.5-flash"
|
|
18
|
+
GREENLIGHT_ENV = "prod"
|
|
19
|
+
|
|
20
|
+
# Output store. `wrangler kv namespace create STATE` → paste the id here (and per-env below).
|
|
21
|
+
[[kv_namespaces]]
|
|
22
|
+
binding = "STATE"
|
|
23
|
+
id = "REPLACE_WITH_KV_NAMESPACE_ID"
|
|
24
|
+
|
|
25
|
+
# Named environments (the deploy uses `wrangler deploy --env prod|beta`). Wrangler envs do NOT
|
|
26
|
+
# inherit top-level config, so vars + kv + routes are repeated per env.
|
|
27
|
+
[env.prod]
|
|
28
|
+
name = "agent-tool"
|
|
29
|
+
routes = [{ pattern = "agent-tool.example.dev", custom_domain = true }]
|
|
30
|
+
|
|
31
|
+
[env.prod.vars]
|
|
32
|
+
MODEL = "gemini-2.5-flash"
|
|
33
|
+
GREENLIGHT_ENV = "prod"
|
|
34
|
+
|
|
35
|
+
[[env.prod.kv_namespaces]]
|
|
36
|
+
binding = "STATE"
|
|
37
|
+
id = "REPLACE_WITH_KV_NAMESPACE_ID"
|
|
38
|
+
|
|
39
|
+
[env.beta]
|
|
40
|
+
name = "agent-tool-beta"
|
|
41
|
+
routes = [{ pattern = "beta.agent-tool.example.dev", custom_domain = true }]
|
|
42
|
+
|
|
43
|
+
[env.beta.vars]
|
|
44
|
+
MODEL = "gemini-2.5-flash"
|
|
45
|
+
GREENLIGHT_ENV = "beta"
|
|
46
|
+
|
|
47
|
+
[[env.beta.kv_namespaces]]
|
|
48
|
+
binding = "STATE"
|
|
49
|
+
id = "REPLACE_WITH_KV_NAMESPACE_ID"
|