@rtrentjones/greenlight 0.2.8 → 0.2.10
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/README.md +16 -12
- package/assets/skills/provider-vercel/SKILL.md +7 -0
- package/dist/bin.js +372 -243
- package/dist/{chunk-VONSDNH4.js → chunk-JRCATCRY.js} +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
# @rtrentjones/greenlight
|
|
2
2
|
|
|
3
3
|
The Greenlight CLI — setup and lifecycle for the [Greenlight](https://github.com/RTrentJones/greenlight)
|
|
4
|
-
harness. Greenlight
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
harness. Greenlight turns a domain + API tokens into a live personal site plus a self-verifying
|
|
5
|
+
agentic deploy loop, with plug-and-play subdomain tools (web apps or MCP servers). It is
|
|
6
|
+
provider-agnostic and free-tier-first, and it **edits declarative infrastructure-as-code — your CI/CD
|
|
7
|
+
applies it**. It is not a hosted PaaS.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
(
|
|
11
|
-
|
|
9
|
+
**You install this CLI; you don't fork the framework.** `greenlight init` scaffolds a thin **wrapper
|
|
10
|
+
repo you own** (your manifest + content) that depends on this package and updates via
|
|
11
|
+
`pnpm update` — no framework code to merge. This is the **single published package**: the CLI, with
|
|
12
|
+
the framework libraries (`shared`/`verify`/`adapters`/`loop`) bundled in. Terraform modules ship as
|
|
13
|
+
git tags (pinned in lockstep with this version); skills ship as a Claude Code plugin.
|
|
12
14
|
|
|
13
|
-
##
|
|
15
|
+
## Quick start
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
|
-
|
|
18
|
+
npx -y @rtrentjones/greenlight init --domain you.dev # scaffold the wrapper + gather base keys
|
|
19
|
+
pnpm greenlight add notes --lane mcp --target oci # add a tool: emit infra + gather ITS keys
|
|
20
|
+
git push # CI runs `terraform apply`
|
|
21
|
+
pnpm greenlight verify notes --env prod # the shared harness proves it
|
|
17
22
|
```
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
code to merge.
|
|
24
|
+
Full walkthrough: [getting-started.md](https://github.com/RTrentJones/greenlight/blob/main/docs/getting-started.md).
|
|
25
|
+
Update the mechanics later with `pnpm update @rtrentjones/greenlight`.
|
|
22
26
|
|
|
23
27
|
Optional peer features lazy-load and degrade to a failing check if absent (never a crash):
|
|
24
28
|
|
|
@@ -42,6 +42,13 @@ wrapper deploy listener. `greenlight adopt … --target vercel` emits, into the
|
|
|
42
42
|
`greenlight verify --url <url> --spec <path>` is the **manifest-free** mode that makes this work
|
|
43
43
|
without carrying the wrapper's `greenlight.config.ts` into the tool repo.
|
|
44
44
|
|
|
45
|
+
**Deployment Protection gotcha:** `deployment_status.target_url` is the `*.vercel.app` *deployment*
|
|
46
|
+
URL, which Vercel **Deployment Protection** gates (→ **401**) even though the public custom domain
|
|
47
|
+
is 200. To verify the real app, create a **Protection Bypass for Automation** secret (Vercel →
|
|
48
|
+
project → Settings → Deployment Protection) and set it as `VERCEL_AUTOMATION_BYPASS_SECRET` on the
|
|
49
|
+
tool repo — the api check sends it as `x-vercel-protection-bypass` and asserts 200. Without it the
|
|
50
|
+
generated spec asserts **401** (the deployment is served + protected), so the gate stays green.
|
|
51
|
+
|
|
45
52
|
## MCP
|
|
46
53
|
|
|
47
54
|
`.mcp.json` wires `vercel` (hosted, OAuth, read-only). Run `/mcp` and authenticate in the
|
package/dist/bin.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
loadConfig,
|
|
6
6
|
resolveUrl,
|
|
7
7
|
verifyAll
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-JRCATCRY.js";
|
|
9
9
|
import "./chunk-ADS6BJJ5.js";
|
|
10
10
|
import "./chunk-WFZTRXBF.js";
|
|
11
11
|
import "./chunk-KP3Y6WRU.js";
|
|
@@ -14,8 +14,8 @@ import "./chunk-6N7MD6FR.js";
|
|
|
14
14
|
import "./chunk-QFKE5JKC.js";
|
|
15
15
|
|
|
16
16
|
// src/commands/add.ts
|
|
17
|
-
import { cpSync as cpSync2, existsSync as
|
|
18
|
-
import { join, resolve as
|
|
17
|
+
import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
18
|
+
import { join, resolve as resolve5 } from "path";
|
|
19
19
|
|
|
20
20
|
// src/asset-paths.ts
|
|
21
21
|
import { existsSync } from "fs";
|
|
@@ -105,6 +105,30 @@ function addTool(config, t) {
|
|
|
105
105
|
}
|
|
106
106
|
return result.data;
|
|
107
107
|
}
|
|
108
|
+
function upsertTool(config, t) {
|
|
109
|
+
if (t.name === "blog") throw new Error('"blog" is a reserved name');
|
|
110
|
+
const entry = {
|
|
111
|
+
name: t.name,
|
|
112
|
+
lane: t.lane,
|
|
113
|
+
target: t.target,
|
|
114
|
+
data: t.data ?? "none",
|
|
115
|
+
auth: t.auth ?? "none",
|
|
116
|
+
access: t.access ?? "public",
|
|
117
|
+
envs: t.envs ?? ["beta", "prod"],
|
|
118
|
+
...t.dir !== void 0 ? { dir: t.dir } : {},
|
|
119
|
+
...t.adopted ? { adopted: true } : {},
|
|
120
|
+
...t.external ? { external: true } : {},
|
|
121
|
+
...t.port !== void 0 ? { port: t.port } : {}
|
|
122
|
+
};
|
|
123
|
+
const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
|
|
124
|
+
const result = ConfigSchema.safeParse({ ...config, tools });
|
|
125
|
+
if (!result.success) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return result.data;
|
|
131
|
+
}
|
|
108
132
|
|
|
109
133
|
// src/manifest.ts
|
|
110
134
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -390,7 +414,7 @@ function tokensForTool(tool) {
|
|
|
390
414
|
}
|
|
391
415
|
|
|
392
416
|
// src/version.ts
|
|
393
|
-
var MODULE_REF = "v0.2.
|
|
417
|
+
var MODULE_REF = "v0.2.10";
|
|
394
418
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
395
419
|
function moduleSource(module, ref = MODULE_REF) {
|
|
396
420
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -630,15 +654,87 @@ function providersForTool(tool) {
|
|
|
630
654
|
return out;
|
|
631
655
|
}
|
|
632
656
|
|
|
633
|
-
// src/
|
|
634
|
-
import { existsSync as
|
|
635
|
-
import { resolve as
|
|
636
|
-
|
|
657
|
+
// src/commands/agent.ts
|
|
658
|
+
import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
659
|
+
import { resolve as resolve3 } from "path";
|
|
660
|
+
|
|
661
|
+
// src/agent-kit.ts
|
|
662
|
+
function recommendedMcp(tool) {
|
|
663
|
+
return mcpForTool(tool);
|
|
664
|
+
}
|
|
665
|
+
function mergeMcpServers(existing, add) {
|
|
666
|
+
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
667
|
+
for (const [name, val] of Object.entries(add)) {
|
|
668
|
+
if (out.mcpServers[name]) continue;
|
|
669
|
+
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/commands/agent.ts
|
|
675
|
+
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
676
|
+
|
|
677
|
+
This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
|
|
678
|
+
branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
|
|
679
|
+
|
|
680
|
+
Agentic kit:
|
|
681
|
+
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
|
|
682
|
+
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
683
|
+
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
684
|
+
- Best-practice skills (one-time, user scope):
|
|
685
|
+
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
686
|
+
`;
|
|
687
|
+
function materializeAgentKit(dir, tool) {
|
|
688
|
+
const src = skillAssetDir();
|
|
689
|
+
if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
|
|
690
|
+
const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
|
|
691
|
+
mkdirSync(dest, { recursive: true });
|
|
692
|
+
cpSync(src, dest, { recursive: true });
|
|
693
|
+
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
694
|
+
for (const pack of packsForTool(tool)) {
|
|
695
|
+
if (!pack.skill) continue;
|
|
696
|
+
const skillSrc = skillAssetDir(pack.skill);
|
|
697
|
+
if (!existsSync3(skillSrc)) continue;
|
|
698
|
+
const skillDest = resolve3(dir, ".claude/skills", pack.skill);
|
|
699
|
+
mkdirSync(skillDest, { recursive: true });
|
|
700
|
+
cpSync(skillSrc, skillDest, { recursive: true });
|
|
701
|
+
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
702
|
+
}
|
|
703
|
+
const mcpPath = resolve3(dir, ".mcp.json");
|
|
704
|
+
const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
|
|
705
|
+
const servers = recommendedMcp(tool);
|
|
706
|
+
writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
707
|
+
`);
|
|
708
|
+
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
709
|
+
const claudePath = resolve3(dir, "CLAUDE.md");
|
|
710
|
+
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
711
|
+
const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
|
|
712
|
+
if (existing.includes(marker)) {
|
|
713
|
+
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
714
|
+
} else {
|
|
715
|
+
writeFileSync(claudePath, existing ? `${existing.trimEnd()}
|
|
716
|
+
|
|
717
|
+
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
718
|
+
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async function agentCommand(args) {
|
|
722
|
+
if (args[0] !== "sync") {
|
|
723
|
+
console.log(
|
|
724
|
+
"usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
|
|
725
|
+
);
|
|
726
|
+
process.exit(args[0] ? 1 : 0);
|
|
727
|
+
}
|
|
728
|
+
materializeAgentKit(process.cwd());
|
|
729
|
+
console.log(
|
|
730
|
+
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
731
|
+
);
|
|
732
|
+
}
|
|
637
733
|
|
|
638
734
|
// src/commands/secrets.ts
|
|
639
735
|
import { execFileSync } from "child_process";
|
|
640
|
-
import { existsSync as
|
|
641
|
-
import { resolve as
|
|
736
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
737
|
+
import { resolve as resolve4 } from "path";
|
|
642
738
|
import { createInterface } from "readline";
|
|
643
739
|
function parseSecretsEnv(text) {
|
|
644
740
|
const out = [];
|
|
@@ -664,7 +760,7 @@ function parseOciConfig(text) {
|
|
|
664
760
|
return out;
|
|
665
761
|
}
|
|
666
762
|
function ociPrefill(configPath, keyPath) {
|
|
667
|
-
const cfg = parseOciConfig(
|
|
763
|
+
const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
|
|
668
764
|
const map = /* @__PURE__ */ new Map();
|
|
669
765
|
const set = (k, v) => {
|
|
670
766
|
if (v) map.set(k, v);
|
|
@@ -674,8 +770,8 @@ function ociPrefill(configPath, keyPath) {
|
|
|
674
770
|
set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
|
|
675
771
|
set("TF_VAR_OCI_REGION", cfg.region);
|
|
676
772
|
const pem = keyPath ?? cfg.key_file;
|
|
677
|
-
if (pem &&
|
|
678
|
-
map.set("TF_VAR_OCI_PRIVATE_KEY",
|
|
773
|
+
if (pem && existsSync4(pem)) {
|
|
774
|
+
map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
|
|
679
775
|
} else if (pem) {
|
|
680
776
|
console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
|
|
681
777
|
}
|
|
@@ -709,11 +805,11 @@ function syncSecrets(opts) {
|
|
|
709
805
|
"could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
|
|
710
806
|
);
|
|
711
807
|
}
|
|
712
|
-
const path =
|
|
713
|
-
if (!
|
|
808
|
+
const path = resolve4(opts.cwd, ".greenlight/secrets.env");
|
|
809
|
+
if (!existsSync4(path)) {
|
|
714
810
|
throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
|
|
715
811
|
}
|
|
716
|
-
const entries = parseSecretsEnv(
|
|
812
|
+
const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
|
|
717
813
|
const target = opts.env ? `env "${opts.env}"` : "repo";
|
|
718
814
|
for (const { key, value } of entries) {
|
|
719
815
|
const ghArgs = ["secret", "set", key, "--repo", repo];
|
|
@@ -855,7 +951,7 @@ async function secretsCommand(args) {
|
|
|
855
951
|
if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
|
|
856
952
|
const ociConfig2 = flag(args, "--oci-config");
|
|
857
953
|
const ociKey = flag(args, "--oci-key");
|
|
858
|
-
const prefill = ociConfig2 ? ociPrefill(
|
|
954
|
+
const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
|
|
859
955
|
await gatherSecrets(name, repo, flag(args, "--env"), prefill);
|
|
860
956
|
return;
|
|
861
957
|
}
|
|
@@ -880,161 +976,6 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
|
|
|
880
976
|
);
|
|
881
977
|
}
|
|
882
978
|
|
|
883
|
-
// src/tokens.ts
|
|
884
|
-
var SECRETS_DIR = ".greenlight";
|
|
885
|
-
var SECRETS_FILE = "secrets.env";
|
|
886
|
-
function presentEnv(cwd) {
|
|
887
|
-
const out = {};
|
|
888
|
-
const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
|
|
889
|
-
if (existsSync4(p)) {
|
|
890
|
-
for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
|
|
891
|
-
}
|
|
892
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
893
|
-
if (v !== void 0 && !(k in out)) out[k] = v;
|
|
894
|
-
}
|
|
895
|
-
return out;
|
|
896
|
-
}
|
|
897
|
-
function upsertSecret(cwd, key, value) {
|
|
898
|
-
const dir = resolve4(cwd, SECRETS_DIR);
|
|
899
|
-
mkdirSync(dir, { recursive: true });
|
|
900
|
-
const p = resolve4(dir, SECRETS_FILE);
|
|
901
|
-
const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
|
|
902
|
-
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
903
|
-
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
904
|
-
else {
|
|
905
|
-
while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
|
|
906
|
-
lines.push(`${key}=${value}`);
|
|
907
|
-
}
|
|
908
|
-
writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
|
|
909
|
-
`, { mode: 384 });
|
|
910
|
-
}
|
|
911
|
-
async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
912
|
-
const doVerify = opts.verify !== false;
|
|
913
|
-
const interactive = Boolean(process.stdin.isTTY);
|
|
914
|
-
const env = presentEnv(cwd);
|
|
915
|
-
const results = [];
|
|
916
|
-
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
|
|
917
|
-
try {
|
|
918
|
-
for (const spec of tokensForTool(tool)) {
|
|
919
|
-
let value = env[spec.envVar];
|
|
920
|
-
if (value) {
|
|
921
|
-
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
922
|
-
} else if (rl) {
|
|
923
|
-
console.log(`
|
|
924
|
-
${spec.envVar} \u2014 ${spec.label}`);
|
|
925
|
-
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
926
|
-
const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
|
|
927
|
-
if (!entered) {
|
|
928
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
929
|
-
continue;
|
|
930
|
-
}
|
|
931
|
-
upsertSecret(cwd, spec.envVar, entered);
|
|
932
|
-
env[spec.envVar] = entered;
|
|
933
|
-
value = entered;
|
|
934
|
-
results.push({ envVar: spec.envVar, outcome: "entered" });
|
|
935
|
-
} else {
|
|
936
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
if (value && doVerify && spec.verify) {
|
|
940
|
-
let check;
|
|
941
|
-
try {
|
|
942
|
-
check = await spec.verify(value, env);
|
|
943
|
-
} catch (e) {
|
|
944
|
-
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
945
|
-
}
|
|
946
|
-
const last = results[results.length - 1];
|
|
947
|
-
if (last) last.verify = check;
|
|
948
|
-
if (!check.ok && !spec.optional) {
|
|
949
|
-
throw new Error(
|
|
950
|
-
`${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
951
|
-
);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
} finally {
|
|
956
|
-
rl?.close();
|
|
957
|
-
}
|
|
958
|
-
return results;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// src/commands/agent.ts
|
|
962
|
-
import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
963
|
-
import { resolve as resolve5 } from "path";
|
|
964
|
-
|
|
965
|
-
// src/agent-kit.ts
|
|
966
|
-
function recommendedMcp(tool) {
|
|
967
|
-
return mcpForTool(tool);
|
|
968
|
-
}
|
|
969
|
-
function mergeMcpServers(existing, add) {
|
|
970
|
-
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
971
|
-
for (const [name, val] of Object.entries(add)) {
|
|
972
|
-
if (out.mcpServers[name]) continue;
|
|
973
|
-
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
974
|
-
}
|
|
975
|
-
return out;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
// src/commands/agent.ts
|
|
979
|
-
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
980
|
-
|
|
981
|
-
This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
|
|
982
|
-
branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
|
|
983
|
-
|
|
984
|
-
Agentic kit:
|
|
985
|
-
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
|
|
986
|
-
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
987
|
-
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
988
|
-
- Best-practice skills (one-time, user scope):
|
|
989
|
-
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
990
|
-
`;
|
|
991
|
-
function materializeAgentKit(dir, tool) {
|
|
992
|
-
const src = skillAssetDir();
|
|
993
|
-
if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
|
|
994
|
-
const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
|
|
995
|
-
mkdirSync2(dest, { recursive: true });
|
|
996
|
-
cpSync(src, dest, { recursive: true });
|
|
997
|
-
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
998
|
-
for (const pack of packsForTool(tool)) {
|
|
999
|
-
if (!pack.skill) continue;
|
|
1000
|
-
const skillSrc = skillAssetDir(pack.skill);
|
|
1001
|
-
if (!existsSync5(skillSrc)) continue;
|
|
1002
|
-
const skillDest = resolve5(dir, ".claude/skills", pack.skill);
|
|
1003
|
-
mkdirSync2(skillDest, { recursive: true });
|
|
1004
|
-
cpSync(skillSrc, skillDest, { recursive: true });
|
|
1005
|
-
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
1006
|
-
}
|
|
1007
|
-
const mcpPath = resolve5(dir, ".mcp.json");
|
|
1008
|
-
const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
|
|
1009
|
-
const servers = recommendedMcp(tool);
|
|
1010
|
-
writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
1011
|
-
`);
|
|
1012
|
-
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
1013
|
-
const claudePath = resolve5(dir, "CLAUDE.md");
|
|
1014
|
-
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
1015
|
-
const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
|
|
1016
|
-
if (existing.includes(marker)) {
|
|
1017
|
-
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
1018
|
-
} else {
|
|
1019
|
-
writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
|
|
1020
|
-
|
|
1021
|
-
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
1022
|
-
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
async function agentCommand(args) {
|
|
1026
|
-
if (args[0] !== "sync") {
|
|
1027
|
-
console.log(
|
|
1028
|
-
"usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
|
|
1029
|
-
);
|
|
1030
|
-
process.exit(args[0] ? 1 : 0);
|
|
1031
|
-
}
|
|
1032
|
-
materializeAgentKit(process.cwd());
|
|
1033
|
-
console.log(
|
|
1034
|
-
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
979
|
// src/commands/add.ts
|
|
1039
980
|
function flag2(args, name) {
|
|
1040
981
|
const i = args.indexOf(name);
|
|
@@ -1072,71 +1013,69 @@ async function addCommand(args) {
|
|
|
1072
1013
|
const data = entry?.data ?? "none";
|
|
1073
1014
|
const envs = entry?.envs ?? ["beta", "prod"];
|
|
1074
1015
|
const toolInfo = { target, data };
|
|
1075
|
-
const dest =
|
|
1076
|
-
if (
|
|
1016
|
+
const dest = resolve5(process.cwd(), "tools", name);
|
|
1017
|
+
if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
|
|
1077
1018
|
const src = templateDir(lane, target);
|
|
1078
|
-
if (
|
|
1019
|
+
if (existsSync5(src)) {
|
|
1079
1020
|
cpSync2(src, dest, { recursive: true });
|
|
1080
1021
|
const pkgPath = join(dest, "package.json");
|
|
1081
|
-
if (
|
|
1082
|
-
const pkg = JSON.parse(
|
|
1022
|
+
if (existsSync5(pkgPath)) {
|
|
1023
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
1083
1024
|
pkg.name = name;
|
|
1084
|
-
|
|
1025
|
+
writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1085
1026
|
`);
|
|
1086
1027
|
}
|
|
1087
1028
|
console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
|
|
1088
1029
|
} else {
|
|
1089
1030
|
console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
|
|
1090
1031
|
}
|
|
1091
|
-
|
|
1032
|
+
writeFileSync2(path, serializeConfig(next));
|
|
1092
1033
|
console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
|
|
1093
1034
|
const cwd = process.cwd();
|
|
1094
1035
|
const providers = providersForTool(toolInfo);
|
|
1095
|
-
const infraDir =
|
|
1036
|
+
const infraDir = resolve5(cwd, "infra");
|
|
1096
1037
|
const mainTf = join(infraDir, "main.tf");
|
|
1097
|
-
if (!
|
|
1098
|
-
|
|
1099
|
-
|
|
1038
|
+
if (!existsSync5(mainTf)) {
|
|
1039
|
+
mkdirSync2(infraDir, { recursive: true });
|
|
1040
|
+
writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
|
|
1100
1041
|
console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
|
|
1101
1042
|
} else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1102
1043
|
console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
|
|
1103
1044
|
}
|
|
1104
1045
|
const toolTf = join(infraDir, `${name}.tf`);
|
|
1105
|
-
if (
|
|
1046
|
+
if (existsSync5(toolTf)) {
|
|
1106
1047
|
console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
|
|
1107
1048
|
} else {
|
|
1108
|
-
|
|
1049
|
+
writeFileSync2(
|
|
1109
1050
|
toolTf,
|
|
1110
1051
|
emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
|
|
1111
1052
|
);
|
|
1112
1053
|
console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
|
|
1113
1054
|
}
|
|
1114
|
-
|
|
1055
|
+
materializeAgentKit(cwd, toolInfo);
|
|
1056
|
+
const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
|
|
1057
|
+
const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
|
|
1058
|
+
if (gather) {
|
|
1059
|
+
console.log(`
|
|
1060
|
+
Gathering ${name}'s provider keys${repo ? ` \u2192 ${repo}` : ""}:`);
|
|
1115
1061
|
try {
|
|
1116
|
-
|
|
1117
|
-
verify: !args.includes("--no-verify")
|
|
1118
|
-
});
|
|
1119
|
-
const missing = outcomes.filter((o) => o.outcome === "missing").map((o) => o.envVar);
|
|
1120
|
-
if (missing.length) {
|
|
1121
|
-
console.log(
|
|
1122
|
-
`! missing token(s): ${missing.join(", ")} \u2014 set in .greenlight/secrets.env, then \`greenlight secrets sync\``
|
|
1123
|
-
);
|
|
1124
|
-
}
|
|
1062
|
+
await gatherSecrets(name, repo, flag2(args, "--env"));
|
|
1125
1063
|
} catch (e) {
|
|
1126
|
-
console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
|
|
1064
|
+
console.log(`\u2716 secrets gather: ${e instanceof Error ? e.message : String(e)}`);
|
|
1065
|
+
console.log(` retry: greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : ""}`);
|
|
1127
1066
|
}
|
|
1128
1067
|
}
|
|
1129
|
-
materializeAgentKit(cwd, toolInfo);
|
|
1130
1068
|
console.log(`
|
|
1131
|
-
Next:
|
|
1069
|
+
Next:${gather ? "" : `
|
|
1070
|
+
greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : " --repo <owner/repo>"} # this tool's keys \u2192 GitHub`}
|
|
1132
1071
|
review infra/${name}.tf, then commit + push \u2192 CI (infra.yml) runs \`terraform apply\`
|
|
1133
1072
|
greenlight preview ${name} # local build + serve + verify`);
|
|
1134
1073
|
}
|
|
1135
1074
|
|
|
1136
1075
|
// src/commands/adopt.ts
|
|
1137
1076
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
1138
|
-
import { cpSync as cpSync3, existsSync as
|
|
1139
|
-
import { join as join2, resolve as
|
|
1077
|
+
import { cpSync as cpSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1078
|
+
import { join as join2, resolve as resolve6 } from "path";
|
|
1140
1079
|
var REF = MODULE_REF;
|
|
1141
1080
|
function flag3(args, name) {
|
|
1142
1081
|
const i = args.indexOf(name);
|
|
@@ -1152,7 +1091,7 @@ function mergePackageJson(existing, repoName, vendor) {
|
|
|
1152
1091
|
}
|
|
1153
1092
|
function vendorDeps(vendorDir) {
|
|
1154
1093
|
const out = {};
|
|
1155
|
-
if (!
|
|
1094
|
+
if (!existsSync6(vendorDir)) return out;
|
|
1156
1095
|
for (const f of readdirSync(vendorDir)) {
|
|
1157
1096
|
if (!f.endsWith(".tgz")) continue;
|
|
1158
1097
|
const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
|
|
@@ -1443,14 +1382,14 @@ jobs:
|
|
|
1443
1382
|
- uses: actions/setup-node@v4
|
|
1444
1383
|
with:
|
|
1445
1384
|
node-version: '24'
|
|
1446
|
-
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile;
|
|
1450
|
-
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile;
|
|
1451
|
-
else npm ci; fi
|
|
1385
|
+
# agent-web needs browsers: add \`- run: npx -y playwright install --with-deps chromium\` when
|
|
1386
|
+
# you set ANTHROPIC_API_KEY. test-mode needs the tool's deps: add a tolerant install step
|
|
1387
|
+
# (\`pnpm install --no-frozen-lockfile\`) \u2014 but unit tests usually belong in the tool's PR CI.
|
|
1452
1388
|
- name: Verify the deployment
|
|
1453
1389
|
env:
|
|
1390
|
+
# Bypass Vercel Deployment Protection on the deployment URL (Vercel \u2192 project \u2192 Deployment
|
|
1391
|
+
# Protection \u2192 Protection Bypass for Automation). Without it the gate asserts 401 (served).
|
|
1392
|
+
VERCEL_AUTOMATION_BYPASS_SECRET: \${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
|
|
1454
1393
|
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
1455
1394
|
run: npx -y @rtrentjones/greenlight@latest verify --url "\${{ github.event.deployment_status.target_url }}" --spec verify/${name}.config.ts
|
|
1456
1395
|
`;
|
|
@@ -1458,10 +1397,20 @@ jobs:
|
|
|
1458
1397
|
function nextVerifyConfig(name) {
|
|
1459
1398
|
return `// Greenlight verify spec for ${name} (next/vercel) \u2014 run by .github/workflows/greenlight-verify.yml
|
|
1460
1399
|
// after Vercel deploys (deployment_status). An array combines modes (allPass):
|
|
1461
|
-
// - api: the
|
|
1462
|
-
//
|
|
1400
|
+
// - api: deployment_status' target_url is the *.vercel.app deployment URL, which Vercel Deployment
|
|
1401
|
+
// Protection gates (401). With VERCEL_AUTOMATION_BYPASS_SECRET set we send the bypass header and
|
|
1402
|
+
// assert 200 (the real app); without it we assert 401 (the deployment is served + protected).
|
|
1463
1403
|
// - agent-web: an LLM drives the live UI; runs ONLY when ANTHROPIC_API_KEY is set (else omitted,
|
|
1464
1404
|
// so the gate stays green). Replace the scenario with real user tasks + assertions.
|
|
1405
|
+
// Unit tests belong in this repo's PR CI; to also gate the deploy on them, add
|
|
1406
|
+
// { mode: 'test', command: 'pnpm test' } + a tolerant deps-install step in greenlight-verify.yml.
|
|
1407
|
+
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
1408
|
+
const api = bypass
|
|
1409
|
+
? {
|
|
1410
|
+
mode: 'api',
|
|
1411
|
+
checks: [{ path: '/', status: 200, requestHeaders: { 'x-vercel-protection-bypass': bypass } }],
|
|
1412
|
+
}
|
|
1413
|
+
: { mode: 'api', checks: [{ path: '/', status: 401 }] };
|
|
1465
1414
|
const agentWeb = process.env.ANTHROPIC_API_KEY
|
|
1466
1415
|
? [
|
|
1467
1416
|
{
|
|
@@ -1477,20 +1426,16 @@ const agentWeb = process.env.ANTHROPIC_API_KEY
|
|
|
1477
1426
|
]
|
|
1478
1427
|
: [];
|
|
1479
1428
|
|
|
1480
|
-
export default [
|
|
1481
|
-
{ mode: 'api', checks: [{ path: '/', status: 200 }] },
|
|
1482
|
-
{ mode: 'test', command: 'npm test' },
|
|
1483
|
-
...agentWeb,
|
|
1484
|
-
];
|
|
1429
|
+
export default [api, ...agentWeb];
|
|
1485
1430
|
`;
|
|
1486
1431
|
}
|
|
1487
1432
|
function writeIfAbsent(path, contents, label) {
|
|
1488
|
-
if (
|
|
1433
|
+
if (existsSync6(path)) {
|
|
1489
1434
|
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
1490
1435
|
return;
|
|
1491
1436
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1437
|
+
mkdirSync3(resolve6(path, ".."), { recursive: true });
|
|
1438
|
+
writeFileSync3(path, contents);
|
|
1494
1439
|
console.log(`\u2714 ${label}`);
|
|
1495
1440
|
}
|
|
1496
1441
|
async function adoptCommand(args) {
|
|
@@ -1514,8 +1459,8 @@ async function adoptCommand(args) {
|
|
|
1514
1459
|
"run adopt from your site repo (needs a real greenlight.config.ts; run `greenlight init` first)"
|
|
1515
1460
|
);
|
|
1516
1461
|
}
|
|
1517
|
-
if (
|
|
1518
|
-
throw new Error(
|
|
1462
|
+
if (name === "blog") {
|
|
1463
|
+
throw new Error('"blog" is the apex site, not an adopted tool');
|
|
1519
1464
|
}
|
|
1520
1465
|
const domain = flag3(args, "--domain") ?? reg.domain;
|
|
1521
1466
|
const ctx = { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath };
|
|
@@ -1526,10 +1471,10 @@ async function adoptWrapper(ctx) {
|
|
|
1526
1471
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1527
1472
|
const cwd = process.cwd();
|
|
1528
1473
|
const toolRel = `tools/${name}`;
|
|
1529
|
-
const dest =
|
|
1474
|
+
const dest = resolve6(cwd, toolRel);
|
|
1530
1475
|
console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
|
|
1531
1476
|
`);
|
|
1532
|
-
if (!
|
|
1477
|
+
if (!existsSync6(dest)) {
|
|
1533
1478
|
try {
|
|
1534
1479
|
execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
|
|
1535
1480
|
console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
|
|
@@ -1542,7 +1487,8 @@ async function adoptWrapper(ctx) {
|
|
|
1542
1487
|
} else {
|
|
1543
1488
|
console.log(`\xB7 ${toolRel} exists \u2014 skipping submodule add`);
|
|
1544
1489
|
}
|
|
1545
|
-
const
|
|
1490
|
+
const existed = reg.tools.some((x) => x.name === name);
|
|
1491
|
+
const nextReg = upsertTool(reg, {
|
|
1546
1492
|
name,
|
|
1547
1493
|
lane,
|
|
1548
1494
|
target,
|
|
@@ -1553,8 +1499,10 @@ async function adoptWrapper(ctx) {
|
|
|
1553
1499
|
external: true,
|
|
1554
1500
|
adopted: true
|
|
1555
1501
|
});
|
|
1556
|
-
|
|
1557
|
-
console.log(
|
|
1502
|
+
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1503
|
+
console.log(
|
|
1504
|
+
`\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
|
|
1505
|
+
);
|
|
1558
1506
|
const slug = parseRepo(repoArg) ?? parseRepo(safeGit(dest, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1559
1507
|
writeIfAbsent(
|
|
1560
1508
|
join2(cwd, `infra/${name}.tf`),
|
|
@@ -1569,7 +1517,7 @@ async function adoptWrapper(ctx) {
|
|
|
1569
1517
|
);
|
|
1570
1518
|
}
|
|
1571
1519
|
const providers = providersForTool({ target, data });
|
|
1572
|
-
if (
|
|
1520
|
+
if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1573
1521
|
console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
|
|
1574
1522
|
}
|
|
1575
1523
|
materializeAgentKit(dest, { target, data });
|
|
@@ -1615,9 +1563,9 @@ Next:
|
|
|
1615
1563
|
}
|
|
1616
1564
|
async function adoptStandalone(ctx) {
|
|
1617
1565
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1618
|
-
const repo =
|
|
1619
|
-
if (!
|
|
1620
|
-
const regVendor =
|
|
1566
|
+
const repo = resolve6(process.cwd(), repoArg);
|
|
1567
|
+
if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
|
|
1568
|
+
const regVendor = resolve6(process.cwd(), "vendor");
|
|
1621
1569
|
const vendor = vendorDeps(regVendor);
|
|
1622
1570
|
if (Object.keys(vendor).length === 0) {
|
|
1623
1571
|
throw new Error(
|
|
@@ -1635,15 +1583,15 @@ async function adoptStandalone(ctx) {
|
|
|
1635
1583
|
);
|
|
1636
1584
|
const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1637
1585
|
const pkgPath = join2(repo, "package.json");
|
|
1638
|
-
const existingPkg =
|
|
1639
|
-
|
|
1586
|
+
const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
|
|
1587
|
+
writeFileSync3(
|
|
1640
1588
|
pkgPath,
|
|
1641
1589
|
`${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
|
|
1642
1590
|
`
|
|
1643
1591
|
);
|
|
1644
1592
|
console.log("\u2714 package.json (merged framework deps + overrides)");
|
|
1645
1593
|
const repoVendor = join2(repo, "vendor");
|
|
1646
|
-
|
|
1594
|
+
mkdirSync3(repoVendor, { recursive: true });
|
|
1647
1595
|
for (const f of readdirSync(regVendor)) {
|
|
1648
1596
|
if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
|
|
1649
1597
|
}
|
|
@@ -1677,7 +1625,7 @@ async function adoptStandalone(ctx) {
|
|
|
1677
1625
|
external: true,
|
|
1678
1626
|
adopted: true
|
|
1679
1627
|
});
|
|
1680
|
-
|
|
1628
|
+
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1681
1629
|
console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
|
|
1682
1630
|
console.log(`
|
|
1683
1631
|
Next (in the adopted repo):
|
|
@@ -1690,20 +1638,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
|
|
|
1690
1638
|
}
|
|
1691
1639
|
function addGreenlightScript(dir) {
|
|
1692
1640
|
const pkgPath = join2(dir, "package.json");
|
|
1693
|
-
if (!
|
|
1641
|
+
if (!existsSync6(pkgPath)) {
|
|
1694
1642
|
console.log(
|
|
1695
1643
|
"\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
|
|
1696
1644
|
);
|
|
1697
1645
|
return;
|
|
1698
1646
|
}
|
|
1699
|
-
const pkg = JSON.parse(
|
|
1647
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1700
1648
|
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
1701
1649
|
if (pkg.scripts.greenlight) {
|
|
1702
1650
|
console.log("\xB7 package.json already has a greenlight script");
|
|
1703
1651
|
return;
|
|
1704
1652
|
}
|
|
1705
1653
|
pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
|
|
1706
|
-
|
|
1654
|
+
writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1707
1655
|
`);
|
|
1708
1656
|
console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
|
|
1709
1657
|
}
|
|
@@ -1846,10 +1794,10 @@ async function deployCommand(args) {
|
|
|
1846
1794
|
}
|
|
1847
1795
|
|
|
1848
1796
|
// src/commands/doctor.ts
|
|
1849
|
-
import { existsSync as
|
|
1797
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1850
1798
|
import { join as join4 } from "path";
|
|
1851
1799
|
function dirCheck(label, dir) {
|
|
1852
|
-
return
|
|
1800
|
+
return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
|
|
1853
1801
|
}
|
|
1854
1802
|
function runDoctor(config, root) {
|
|
1855
1803
|
const checks = [];
|
|
@@ -1871,8 +1819,8 @@ function runDoctor(config, root) {
|
|
|
1871
1819
|
const vc = join4(dir, "verify.config.ts");
|
|
1872
1820
|
checks.push({
|
|
1873
1821
|
name: `${t.name}: verify.config.ts`,
|
|
1874
|
-
status:
|
|
1875
|
-
detail:
|
|
1822
|
+
status: existsSync7(vc) ? "ok" : "warn",
|
|
1823
|
+
detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
|
|
1876
1824
|
});
|
|
1877
1825
|
}
|
|
1878
1826
|
}
|
|
@@ -1918,10 +1866,180 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
|
1918
1866
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1919
1867
|
import { resolve as resolve8 } from "path";
|
|
1920
1868
|
import { createInterface as createInterface3 } from "readline/promises";
|
|
1869
|
+
|
|
1870
|
+
// src/tokens.ts
|
|
1871
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
1872
|
+
import { resolve as resolve7 } from "path";
|
|
1873
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
1874
|
+
var SECRETS_DIR = ".greenlight";
|
|
1875
|
+
var SECRETS_FILE = "secrets.env";
|
|
1876
|
+
function presentEnv(cwd) {
|
|
1877
|
+
const out = {};
|
|
1878
|
+
const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
|
|
1879
|
+
if (existsSync8(p)) {
|
|
1880
|
+
for (const { key, value } of parseSecretsEnv(readFileSync5(p, "utf8"))) out[key] = value;
|
|
1881
|
+
}
|
|
1882
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
1883
|
+
if (v !== void 0 && !(k in out)) out[k] = v;
|
|
1884
|
+
}
|
|
1885
|
+
return out;
|
|
1886
|
+
}
|
|
1887
|
+
function upsertSecret(cwd, key, value) {
|
|
1888
|
+
const dir = resolve7(cwd, SECRETS_DIR);
|
|
1889
|
+
mkdirSync4(dir, { recursive: true });
|
|
1890
|
+
const p = resolve7(dir, SECRETS_FILE);
|
|
1891
|
+
const lines = existsSync8(p) ? readFileSync5(p, "utf8").split("\n") : [];
|
|
1892
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
1893
|
+
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
1894
|
+
else {
|
|
1895
|
+
while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
|
|
1896
|
+
lines.push(`${key}=${value}`);
|
|
1897
|
+
}
|
|
1898
|
+
writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
|
|
1899
|
+
`, { mode: 384 });
|
|
1900
|
+
}
|
|
1901
|
+
async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
1902
|
+
const doVerify = opts.verify !== false;
|
|
1903
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
1904
|
+
const env = presentEnv(cwd);
|
|
1905
|
+
const results = [];
|
|
1906
|
+
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
|
|
1907
|
+
try {
|
|
1908
|
+
for (const spec of tokensForTool(tool)) {
|
|
1909
|
+
let value = env[spec.envVar];
|
|
1910
|
+
if (value) {
|
|
1911
|
+
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
1912
|
+
} else if (rl) {
|
|
1913
|
+
console.log(`
|
|
1914
|
+
${spec.envVar} \u2014 ${spec.label}`);
|
|
1915
|
+
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
1916
|
+
const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
|
|
1917
|
+
if (!entered) {
|
|
1918
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
upsertSecret(cwd, spec.envVar, entered);
|
|
1922
|
+
env[spec.envVar] = entered;
|
|
1923
|
+
value = entered;
|
|
1924
|
+
results.push({ envVar: spec.envVar, outcome: "entered" });
|
|
1925
|
+
} else {
|
|
1926
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
if (value && doVerify && spec.verify) {
|
|
1930
|
+
let check;
|
|
1931
|
+
try {
|
|
1932
|
+
check = await spec.verify(value, env);
|
|
1933
|
+
} catch (e) {
|
|
1934
|
+
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
1935
|
+
}
|
|
1936
|
+
const last = results[results.length - 1];
|
|
1937
|
+
if (last) last.verify = check;
|
|
1938
|
+
if (!check.ok && !spec.optional) {
|
|
1939
|
+
throw new Error(
|
|
1940
|
+
`${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
} finally {
|
|
1946
|
+
rl?.close();
|
|
1947
|
+
}
|
|
1948
|
+
return results;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// src/commands/init.ts
|
|
1921
1952
|
function flag5(args, name) {
|
|
1922
1953
|
const i = args.indexOf(name);
|
|
1923
1954
|
return i >= 0 ? args[i + 1] : void 0;
|
|
1924
1955
|
}
|
|
1956
|
+
var NPM_DEP = `^${MODULE_REF.replace(/^v/, "")}`;
|
|
1957
|
+
function wrapperPackageJson(name) {
|
|
1958
|
+
return `${JSON.stringify(
|
|
1959
|
+
{
|
|
1960
|
+
name,
|
|
1961
|
+
private: true,
|
|
1962
|
+
type: "module",
|
|
1963
|
+
scripts: { greenlight: "greenlight" },
|
|
1964
|
+
dependencies: { "@rtrentjones/greenlight": NPM_DEP }
|
|
1965
|
+
},
|
|
1966
|
+
null,
|
|
1967
|
+
2
|
|
1968
|
+
)}
|
|
1969
|
+
`;
|
|
1970
|
+
}
|
|
1971
|
+
var WRAPPER_GITIGNORE = `# Greenlight wrapper
|
|
1972
|
+
node_modules/
|
|
1973
|
+
.greenlight/ # gathered tokens \u2014 never committed
|
|
1974
|
+
.terraform/
|
|
1975
|
+
*.tfplan
|
|
1976
|
+
tf.plan
|
|
1977
|
+
dist/
|
|
1978
|
+
`;
|
|
1979
|
+
var WRAPPER_MISE = `[tools]
|
|
1980
|
+
node = "24"
|
|
1981
|
+
pnpm = "10.12.1"
|
|
1982
|
+
`;
|
|
1983
|
+
function wrapperInfraYml() {
|
|
1984
|
+
return `name: infra
|
|
1985
|
+
|
|
1986
|
+
# Apply the wrapper's Terraform on push to main (paths: infra/**). State + locking are in HCP
|
|
1987
|
+
# Terraform (set the cloud{} block in infra/main.tf); the run happens here with provider creds
|
|
1988
|
+
# from GitHub Actions secrets (populate them per tool via \`greenlight secrets gather\`).
|
|
1989
|
+
on:
|
|
1990
|
+
push:
|
|
1991
|
+
branches: [main]
|
|
1992
|
+
paths: ['infra/**']
|
|
1993
|
+
workflow_dispatch:
|
|
1994
|
+
|
|
1995
|
+
permissions:
|
|
1996
|
+
contents: read
|
|
1997
|
+
|
|
1998
|
+
concurrency:
|
|
1999
|
+
group: infra
|
|
2000
|
+
cancel-in-progress: false # never interrupt an in-flight apply
|
|
2001
|
+
|
|
2002
|
+
jobs:
|
|
2003
|
+
apply:
|
|
2004
|
+
runs-on: ubuntu-latest
|
|
2005
|
+
env:
|
|
2006
|
+
TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
|
|
2007
|
+
GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
|
|
2008
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
2009
|
+
TF_VAR_cloudflare_zone_id: \${{ secrets.TF_VAR_CLOUDFLARE_ZONE_ID }}
|
|
2010
|
+
TF_VAR_cloudflare_account_id: \${{ secrets.TF_VAR_CLOUDFLARE_ACCOUNT_ID }}
|
|
2011
|
+
# vercel (target: vercel tools)
|
|
2012
|
+
VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
|
|
2013
|
+
# supabase (data: supabase tools)
|
|
2014
|
+
SUPABASE_ACCESS_TOKEN: \${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
2015
|
+
TF_VAR_supabase_database_password: \${{ secrets.TF_VAR_SUPABASE_DATABASE_PASSWORD }}
|
|
2016
|
+
# oci (target: oci tools) \u2014 VCN/subnet/AD are IaC; only auth (+ optional compartment) here
|
|
2017
|
+
TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
|
|
2018
|
+
TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
|
|
2019
|
+
TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
|
|
2020
|
+
TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
|
|
2021
|
+
TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
|
|
2022
|
+
TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
|
|
2023
|
+
steps:
|
|
2024
|
+
- uses: actions/checkout@v4
|
|
2025
|
+
- uses: hashicorp/setup-terraform@v3
|
|
2026
|
+
with:
|
|
2027
|
+
terraform_version: '~1.10'
|
|
2028
|
+
terraform_wrapper: false
|
|
2029
|
+
- run: terraform -chdir=infra init -input=false
|
|
2030
|
+
- run: terraform -chdir=infra plan -input=false -out=tf.plan
|
|
2031
|
+
- run: terraform -chdir=infra apply -input=false tf.plan
|
|
2032
|
+
`;
|
|
2033
|
+
}
|
|
2034
|
+
function scaffoldIfAbsent(path, contents, label) {
|
|
2035
|
+
if (existsSync9(path)) {
|
|
2036
|
+
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
mkdirSync5(resolve8(path, ".."), { recursive: true });
|
|
2040
|
+
writeFileSync5(path, contents);
|
|
2041
|
+
console.log(`\u2714 wrote ${label}`);
|
|
2042
|
+
}
|
|
1925
2043
|
var TOKEN_FLAGS = {
|
|
1926
2044
|
"--cf-token": "CLOUDFLARE_API_TOKEN",
|
|
1927
2045
|
"--github-token": "GITHUB_TOKEN",
|
|
@@ -1946,6 +2064,16 @@ async function initCommand(args) {
|
|
|
1946
2064
|
}
|
|
1947
2065
|
writeFileSync5(configPath, scaffoldConfig(domain));
|
|
1948
2066
|
console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
|
|
2067
|
+
const repoName = domain.replace(/\./g, "-");
|
|
2068
|
+
scaffoldIfAbsent(
|
|
2069
|
+
resolve8(cwd, ".github/workflows/infra.yml"),
|
|
2070
|
+
wrapperInfraYml(),
|
|
2071
|
+
".github/workflows/infra.yml (HCP-backed terraform apply on push)"
|
|
2072
|
+
);
|
|
2073
|
+
scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
|
|
2074
|
+
scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
|
|
2075
|
+
scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
|
|
2076
|
+
scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
|
|
1949
2077
|
const secrets = [];
|
|
1950
2078
|
for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
|
|
1951
2079
|
const v = flag5(args, f);
|
|
@@ -1979,10 +2107,11 @@ async function initCommand(args) {
|
|
|
1979
2107
|
}
|
|
1980
2108
|
console.log(`
|
|
1981
2109
|
Next:
|
|
1982
|
-
greenlight add <name> --lane
|
|
1983
|
-
greenlight
|
|
1984
|
-
|
|
1985
|
-
|
|
2110
|
+
1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
|
|
2111
|
+
# gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
|
|
2112
|
+
2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state-r2.md
|
|
2113
|
+
3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
|
|
2114
|
+
4. greenlight verify <name> --env prod | greenlight doctor`);
|
|
1986
2115
|
}
|
|
1987
2116
|
|
|
1988
2117
|
// src/commands/preview.ts
|
|
@@ -116,7 +116,7 @@ var trimSlash = (s) => s.replace(/\/+$/, "");
|
|
|
116
116
|
async function checkRoute(base, c) {
|
|
117
117
|
const name = `GET ${c.path}`;
|
|
118
118
|
try {
|
|
119
|
-
const res = await fetch(base + c.path, { redirect: "manual" });
|
|
119
|
+
const res = await fetch(base + c.path, { redirect: "manual", headers: c.requestHeaders });
|
|
120
120
|
const reasons = [];
|
|
121
121
|
if (c.status !== void 0 && res.status !== c.status) {
|
|
122
122
|
reasons.push(`status ${res.status} != ${c.status}`);
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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-
|
|
34
|
+
"@rtrentjones/greenlight-loop": "0.2.4",
|
|
35
35
|
"@rtrentjones/greenlight-shared": "0.2.4",
|
|
36
36
|
"@rtrentjones/greenlight-verify": "0.2.4",
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
37
|
+
"@rtrentjones/greenlight-adapters": "0.2.4"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node scripts/copy-assets.mjs && tsup",
|