@rtrentjones/greenlight 0.2.9 → 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/dist/bin.js +321 -225
- 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
|
|
package/dist/bin.js
CHANGED
|
@@ -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";
|
|
@@ -414,7 +414,7 @@ function tokensForTool(tool) {
|
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
// src/version.ts
|
|
417
|
-
var MODULE_REF = "v0.2.
|
|
417
|
+
var MODULE_REF = "v0.2.10";
|
|
418
418
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
419
419
|
function moduleSource(module, ref = MODULE_REF) {
|
|
420
420
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -654,15 +654,87 @@ function providersForTool(tool) {
|
|
|
654
654
|
return out;
|
|
655
655
|
}
|
|
656
656
|
|
|
657
|
-
// src/
|
|
658
|
-
import { existsSync as
|
|
659
|
-
import { resolve as
|
|
660
|
-
|
|
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
|
+
}
|
|
661
733
|
|
|
662
734
|
// src/commands/secrets.ts
|
|
663
735
|
import { execFileSync } from "child_process";
|
|
664
|
-
import { existsSync as
|
|
665
|
-
import { resolve as
|
|
736
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
737
|
+
import { resolve as resolve4 } from "path";
|
|
666
738
|
import { createInterface } from "readline";
|
|
667
739
|
function parseSecretsEnv(text) {
|
|
668
740
|
const out = [];
|
|
@@ -688,7 +760,7 @@ function parseOciConfig(text) {
|
|
|
688
760
|
return out;
|
|
689
761
|
}
|
|
690
762
|
function ociPrefill(configPath, keyPath) {
|
|
691
|
-
const cfg = parseOciConfig(
|
|
763
|
+
const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
|
|
692
764
|
const map = /* @__PURE__ */ new Map();
|
|
693
765
|
const set = (k, v) => {
|
|
694
766
|
if (v) map.set(k, v);
|
|
@@ -698,8 +770,8 @@ function ociPrefill(configPath, keyPath) {
|
|
|
698
770
|
set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
|
|
699
771
|
set("TF_VAR_OCI_REGION", cfg.region);
|
|
700
772
|
const pem = keyPath ?? cfg.key_file;
|
|
701
|
-
if (pem &&
|
|
702
|
-
map.set("TF_VAR_OCI_PRIVATE_KEY",
|
|
773
|
+
if (pem && existsSync4(pem)) {
|
|
774
|
+
map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
|
|
703
775
|
} else if (pem) {
|
|
704
776
|
console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
|
|
705
777
|
}
|
|
@@ -733,11 +805,11 @@ function syncSecrets(opts) {
|
|
|
733
805
|
"could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
|
|
734
806
|
);
|
|
735
807
|
}
|
|
736
|
-
const path =
|
|
737
|
-
if (!
|
|
808
|
+
const path = resolve4(opts.cwd, ".greenlight/secrets.env");
|
|
809
|
+
if (!existsSync4(path)) {
|
|
738
810
|
throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
|
|
739
811
|
}
|
|
740
|
-
const entries = parseSecretsEnv(
|
|
812
|
+
const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
|
|
741
813
|
const target = opts.env ? `env "${opts.env}"` : "repo";
|
|
742
814
|
for (const { key, value } of entries) {
|
|
743
815
|
const ghArgs = ["secret", "set", key, "--repo", repo];
|
|
@@ -879,7 +951,7 @@ async function secretsCommand(args) {
|
|
|
879
951
|
if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
|
|
880
952
|
const ociConfig2 = flag(args, "--oci-config");
|
|
881
953
|
const ociKey = flag(args, "--oci-key");
|
|
882
|
-
const prefill = ociConfig2 ? ociPrefill(
|
|
954
|
+
const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
|
|
883
955
|
await gatherSecrets(name, repo, flag(args, "--env"), prefill);
|
|
884
956
|
return;
|
|
885
957
|
}
|
|
@@ -904,161 +976,6 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
|
|
|
904
976
|
);
|
|
905
977
|
}
|
|
906
978
|
|
|
907
|
-
// src/tokens.ts
|
|
908
|
-
var SECRETS_DIR = ".greenlight";
|
|
909
|
-
var SECRETS_FILE = "secrets.env";
|
|
910
|
-
function presentEnv(cwd) {
|
|
911
|
-
const out = {};
|
|
912
|
-
const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
|
|
913
|
-
if (existsSync4(p)) {
|
|
914
|
-
for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
|
|
915
|
-
}
|
|
916
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
917
|
-
if (v !== void 0 && !(k in out)) out[k] = v;
|
|
918
|
-
}
|
|
919
|
-
return out;
|
|
920
|
-
}
|
|
921
|
-
function upsertSecret(cwd, key, value) {
|
|
922
|
-
const dir = resolve4(cwd, SECRETS_DIR);
|
|
923
|
-
mkdirSync(dir, { recursive: true });
|
|
924
|
-
const p = resolve4(dir, SECRETS_FILE);
|
|
925
|
-
const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
|
|
926
|
-
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
927
|
-
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
928
|
-
else {
|
|
929
|
-
while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
|
|
930
|
-
lines.push(`${key}=${value}`);
|
|
931
|
-
}
|
|
932
|
-
writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
|
|
933
|
-
`, { mode: 384 });
|
|
934
|
-
}
|
|
935
|
-
async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
936
|
-
const doVerify = opts.verify !== false;
|
|
937
|
-
const interactive = Boolean(process.stdin.isTTY);
|
|
938
|
-
const env = presentEnv(cwd);
|
|
939
|
-
const results = [];
|
|
940
|
-
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
|
|
941
|
-
try {
|
|
942
|
-
for (const spec of tokensForTool(tool)) {
|
|
943
|
-
let value = env[spec.envVar];
|
|
944
|
-
if (value) {
|
|
945
|
-
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
946
|
-
} else if (rl) {
|
|
947
|
-
console.log(`
|
|
948
|
-
${spec.envVar} \u2014 ${spec.label}`);
|
|
949
|
-
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
950
|
-
const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
|
|
951
|
-
if (!entered) {
|
|
952
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
953
|
-
continue;
|
|
954
|
-
}
|
|
955
|
-
upsertSecret(cwd, spec.envVar, entered);
|
|
956
|
-
env[spec.envVar] = entered;
|
|
957
|
-
value = entered;
|
|
958
|
-
results.push({ envVar: spec.envVar, outcome: "entered" });
|
|
959
|
-
} else {
|
|
960
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
|
-
if (value && doVerify && spec.verify) {
|
|
964
|
-
let check;
|
|
965
|
-
try {
|
|
966
|
-
check = await spec.verify(value, env);
|
|
967
|
-
} catch (e) {
|
|
968
|
-
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
969
|
-
}
|
|
970
|
-
const last = results[results.length - 1];
|
|
971
|
-
if (last) last.verify = check;
|
|
972
|
-
if (!check.ok && !spec.optional) {
|
|
973
|
-
throw new Error(
|
|
974
|
-
`${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
975
|
-
);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
} finally {
|
|
980
|
-
rl?.close();
|
|
981
|
-
}
|
|
982
|
-
return results;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// src/commands/agent.ts
|
|
986
|
-
import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
987
|
-
import { resolve as resolve5 } from "path";
|
|
988
|
-
|
|
989
|
-
// src/agent-kit.ts
|
|
990
|
-
function recommendedMcp(tool) {
|
|
991
|
-
return mcpForTool(tool);
|
|
992
|
-
}
|
|
993
|
-
function mergeMcpServers(existing, add) {
|
|
994
|
-
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
995
|
-
for (const [name, val] of Object.entries(add)) {
|
|
996
|
-
if (out.mcpServers[name]) continue;
|
|
997
|
-
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
998
|
-
}
|
|
999
|
-
return out;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// src/commands/agent.ts
|
|
1003
|
-
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
1004
|
-
|
|
1005
|
-
This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
|
|
1006
|
-
branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
|
|
1007
|
-
|
|
1008
|
-
Agentic kit:
|
|
1009
|
-
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
|
|
1010
|
-
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
1011
|
-
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
1012
|
-
- Best-practice skills (one-time, user scope):
|
|
1013
|
-
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
1014
|
-
`;
|
|
1015
|
-
function materializeAgentKit(dir, tool) {
|
|
1016
|
-
const src = skillAssetDir();
|
|
1017
|
-
if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
|
|
1018
|
-
const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
|
|
1019
|
-
mkdirSync2(dest, { recursive: true });
|
|
1020
|
-
cpSync(src, dest, { recursive: true });
|
|
1021
|
-
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
1022
|
-
for (const pack of packsForTool(tool)) {
|
|
1023
|
-
if (!pack.skill) continue;
|
|
1024
|
-
const skillSrc = skillAssetDir(pack.skill);
|
|
1025
|
-
if (!existsSync5(skillSrc)) continue;
|
|
1026
|
-
const skillDest = resolve5(dir, ".claude/skills", pack.skill);
|
|
1027
|
-
mkdirSync2(skillDest, { recursive: true });
|
|
1028
|
-
cpSync(skillSrc, skillDest, { recursive: true });
|
|
1029
|
-
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
1030
|
-
}
|
|
1031
|
-
const mcpPath = resolve5(dir, ".mcp.json");
|
|
1032
|
-
const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
|
|
1033
|
-
const servers = recommendedMcp(tool);
|
|
1034
|
-
writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
1035
|
-
`);
|
|
1036
|
-
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
1037
|
-
const claudePath = resolve5(dir, "CLAUDE.md");
|
|
1038
|
-
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
1039
|
-
const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
|
|
1040
|
-
if (existing.includes(marker)) {
|
|
1041
|
-
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
1042
|
-
} else {
|
|
1043
|
-
writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
|
|
1044
|
-
|
|
1045
|
-
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
1046
|
-
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
async function agentCommand(args) {
|
|
1050
|
-
if (args[0] !== "sync") {
|
|
1051
|
-
console.log(
|
|
1052
|
-
"usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
|
|
1053
|
-
);
|
|
1054
|
-
process.exit(args[0] ? 1 : 0);
|
|
1055
|
-
}
|
|
1056
|
-
materializeAgentKit(process.cwd());
|
|
1057
|
-
console.log(
|
|
1058
|
-
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
979
|
// src/commands/add.ts
|
|
1063
980
|
function flag2(args, name) {
|
|
1064
981
|
const i = args.indexOf(name);
|
|
@@ -1096,71 +1013,69 @@ async function addCommand(args) {
|
|
|
1096
1013
|
const data = entry?.data ?? "none";
|
|
1097
1014
|
const envs = entry?.envs ?? ["beta", "prod"];
|
|
1098
1015
|
const toolInfo = { target, data };
|
|
1099
|
-
const dest =
|
|
1100
|
-
if (
|
|
1016
|
+
const dest = resolve5(process.cwd(), "tools", name);
|
|
1017
|
+
if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
|
|
1101
1018
|
const src = templateDir(lane, target);
|
|
1102
|
-
if (
|
|
1019
|
+
if (existsSync5(src)) {
|
|
1103
1020
|
cpSync2(src, dest, { recursive: true });
|
|
1104
1021
|
const pkgPath = join(dest, "package.json");
|
|
1105
|
-
if (
|
|
1106
|
-
const pkg = JSON.parse(
|
|
1022
|
+
if (existsSync5(pkgPath)) {
|
|
1023
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
1107
1024
|
pkg.name = name;
|
|
1108
|
-
|
|
1025
|
+
writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1109
1026
|
`);
|
|
1110
1027
|
}
|
|
1111
1028
|
console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
|
|
1112
1029
|
} else {
|
|
1113
1030
|
console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
|
|
1114
1031
|
}
|
|
1115
|
-
|
|
1032
|
+
writeFileSync2(path, serializeConfig(next));
|
|
1116
1033
|
console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
|
|
1117
1034
|
const cwd = process.cwd();
|
|
1118
1035
|
const providers = providersForTool(toolInfo);
|
|
1119
|
-
const infraDir =
|
|
1036
|
+
const infraDir = resolve5(cwd, "infra");
|
|
1120
1037
|
const mainTf = join(infraDir, "main.tf");
|
|
1121
|
-
if (!
|
|
1122
|
-
|
|
1123
|
-
|
|
1038
|
+
if (!existsSync5(mainTf)) {
|
|
1039
|
+
mkdirSync2(infraDir, { recursive: true });
|
|
1040
|
+
writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
|
|
1124
1041
|
console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
|
|
1125
1042
|
} else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1126
1043
|
console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
|
|
1127
1044
|
}
|
|
1128
1045
|
const toolTf = join(infraDir, `${name}.tf`);
|
|
1129
|
-
if (
|
|
1046
|
+
if (existsSync5(toolTf)) {
|
|
1130
1047
|
console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
|
|
1131
1048
|
} else {
|
|
1132
|
-
|
|
1049
|
+
writeFileSync2(
|
|
1133
1050
|
toolTf,
|
|
1134
1051
|
emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
|
|
1135
1052
|
);
|
|
1136
1053
|
console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
|
|
1137
1054
|
}
|
|
1138
|
-
|
|
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}` : ""}:`);
|
|
1139
1061
|
try {
|
|
1140
|
-
|
|
1141
|
-
verify: !args.includes("--no-verify")
|
|
1142
|
-
});
|
|
1143
|
-
const missing = outcomes.filter((o) => o.outcome === "missing").map((o) => o.envVar);
|
|
1144
|
-
if (missing.length) {
|
|
1145
|
-
console.log(
|
|
1146
|
-
`! missing token(s): ${missing.join(", ")} \u2014 set in .greenlight/secrets.env, then \`greenlight secrets sync\``
|
|
1147
|
-
);
|
|
1148
|
-
}
|
|
1062
|
+
await gatherSecrets(name, repo, flag2(args, "--env"));
|
|
1149
1063
|
} catch (e) {
|
|
1150
|
-
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}` : ""}`);
|
|
1151
1066
|
}
|
|
1152
1067
|
}
|
|
1153
|
-
materializeAgentKit(cwd, toolInfo);
|
|
1154
1068
|
console.log(`
|
|
1155
|
-
Next:
|
|
1069
|
+
Next:${gather ? "" : `
|
|
1070
|
+
greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : " --repo <owner/repo>"} # this tool's keys \u2192 GitHub`}
|
|
1156
1071
|
review infra/${name}.tf, then commit + push \u2192 CI (infra.yml) runs \`terraform apply\`
|
|
1157
1072
|
greenlight preview ${name} # local build + serve + verify`);
|
|
1158
1073
|
}
|
|
1159
1074
|
|
|
1160
1075
|
// src/commands/adopt.ts
|
|
1161
1076
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
1162
|
-
import { cpSync as cpSync3, existsSync as
|
|
1163
|
-
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";
|
|
1164
1079
|
var REF = MODULE_REF;
|
|
1165
1080
|
function flag3(args, name) {
|
|
1166
1081
|
const i = args.indexOf(name);
|
|
@@ -1176,7 +1091,7 @@ function mergePackageJson(existing, repoName, vendor) {
|
|
|
1176
1091
|
}
|
|
1177
1092
|
function vendorDeps(vendorDir) {
|
|
1178
1093
|
const out = {};
|
|
1179
|
-
if (!
|
|
1094
|
+
if (!existsSync6(vendorDir)) return out;
|
|
1180
1095
|
for (const f of readdirSync(vendorDir)) {
|
|
1181
1096
|
if (!f.endsWith(".tgz")) continue;
|
|
1182
1097
|
const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
|
|
@@ -1515,12 +1430,12 @@ export default [api, ...agentWeb];
|
|
|
1515
1430
|
`;
|
|
1516
1431
|
}
|
|
1517
1432
|
function writeIfAbsent(path, contents, label) {
|
|
1518
|
-
if (
|
|
1433
|
+
if (existsSync6(path)) {
|
|
1519
1434
|
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
1520
1435
|
return;
|
|
1521
1436
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1437
|
+
mkdirSync3(resolve6(path, ".."), { recursive: true });
|
|
1438
|
+
writeFileSync3(path, contents);
|
|
1524
1439
|
console.log(`\u2714 ${label}`);
|
|
1525
1440
|
}
|
|
1526
1441
|
async function adoptCommand(args) {
|
|
@@ -1556,10 +1471,10 @@ async function adoptWrapper(ctx) {
|
|
|
1556
1471
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1557
1472
|
const cwd = process.cwd();
|
|
1558
1473
|
const toolRel = `tools/${name}`;
|
|
1559
|
-
const dest =
|
|
1474
|
+
const dest = resolve6(cwd, toolRel);
|
|
1560
1475
|
console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
|
|
1561
1476
|
`);
|
|
1562
|
-
if (!
|
|
1477
|
+
if (!existsSync6(dest)) {
|
|
1563
1478
|
try {
|
|
1564
1479
|
execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
|
|
1565
1480
|
console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
|
|
@@ -1584,7 +1499,7 @@ async function adoptWrapper(ctx) {
|
|
|
1584
1499
|
external: true,
|
|
1585
1500
|
adopted: true
|
|
1586
1501
|
});
|
|
1587
|
-
|
|
1502
|
+
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1588
1503
|
console.log(
|
|
1589
1504
|
`\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
|
|
1590
1505
|
);
|
|
@@ -1602,7 +1517,7 @@ async function adoptWrapper(ctx) {
|
|
|
1602
1517
|
);
|
|
1603
1518
|
}
|
|
1604
1519
|
const providers = providersForTool({ target, data });
|
|
1605
|
-
if (
|
|
1520
|
+
if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1606
1521
|
console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
|
|
1607
1522
|
}
|
|
1608
1523
|
materializeAgentKit(dest, { target, data });
|
|
@@ -1648,9 +1563,9 @@ Next:
|
|
|
1648
1563
|
}
|
|
1649
1564
|
async function adoptStandalone(ctx) {
|
|
1650
1565
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1651
|
-
const repo =
|
|
1652
|
-
if (!
|
|
1653
|
-
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");
|
|
1654
1569
|
const vendor = vendorDeps(regVendor);
|
|
1655
1570
|
if (Object.keys(vendor).length === 0) {
|
|
1656
1571
|
throw new Error(
|
|
@@ -1668,15 +1583,15 @@ async function adoptStandalone(ctx) {
|
|
|
1668
1583
|
);
|
|
1669
1584
|
const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1670
1585
|
const pkgPath = join2(repo, "package.json");
|
|
1671
|
-
const existingPkg =
|
|
1672
|
-
|
|
1586
|
+
const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
|
|
1587
|
+
writeFileSync3(
|
|
1673
1588
|
pkgPath,
|
|
1674
1589
|
`${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
|
|
1675
1590
|
`
|
|
1676
1591
|
);
|
|
1677
1592
|
console.log("\u2714 package.json (merged framework deps + overrides)");
|
|
1678
1593
|
const repoVendor = join2(repo, "vendor");
|
|
1679
|
-
|
|
1594
|
+
mkdirSync3(repoVendor, { recursive: true });
|
|
1680
1595
|
for (const f of readdirSync(regVendor)) {
|
|
1681
1596
|
if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
|
|
1682
1597
|
}
|
|
@@ -1710,7 +1625,7 @@ async function adoptStandalone(ctx) {
|
|
|
1710
1625
|
external: true,
|
|
1711
1626
|
adopted: true
|
|
1712
1627
|
});
|
|
1713
|
-
|
|
1628
|
+
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1714
1629
|
console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
|
|
1715
1630
|
console.log(`
|
|
1716
1631
|
Next (in the adopted repo):
|
|
@@ -1723,20 +1638,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
|
|
|
1723
1638
|
}
|
|
1724
1639
|
function addGreenlightScript(dir) {
|
|
1725
1640
|
const pkgPath = join2(dir, "package.json");
|
|
1726
|
-
if (!
|
|
1641
|
+
if (!existsSync6(pkgPath)) {
|
|
1727
1642
|
console.log(
|
|
1728
1643
|
"\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
|
|
1729
1644
|
);
|
|
1730
1645
|
return;
|
|
1731
1646
|
}
|
|
1732
|
-
const pkg = JSON.parse(
|
|
1647
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1733
1648
|
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
1734
1649
|
if (pkg.scripts.greenlight) {
|
|
1735
1650
|
console.log("\xB7 package.json already has a greenlight script");
|
|
1736
1651
|
return;
|
|
1737
1652
|
}
|
|
1738
1653
|
pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
|
|
1739
|
-
|
|
1654
|
+
writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1740
1655
|
`);
|
|
1741
1656
|
console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
|
|
1742
1657
|
}
|
|
@@ -1879,10 +1794,10 @@ async function deployCommand(args) {
|
|
|
1879
1794
|
}
|
|
1880
1795
|
|
|
1881
1796
|
// src/commands/doctor.ts
|
|
1882
|
-
import { existsSync as
|
|
1797
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1883
1798
|
import { join as join4 } from "path";
|
|
1884
1799
|
function dirCheck(label, dir) {
|
|
1885
|
-
return
|
|
1800
|
+
return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
|
|
1886
1801
|
}
|
|
1887
1802
|
function runDoctor(config, root) {
|
|
1888
1803
|
const checks = [];
|
|
@@ -1904,8 +1819,8 @@ function runDoctor(config, root) {
|
|
|
1904
1819
|
const vc = join4(dir, "verify.config.ts");
|
|
1905
1820
|
checks.push({
|
|
1906
1821
|
name: `${t.name}: verify.config.ts`,
|
|
1907
|
-
status:
|
|
1908
|
-
detail:
|
|
1822
|
+
status: existsSync7(vc) ? "ok" : "warn",
|
|
1823
|
+
detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
|
|
1909
1824
|
});
|
|
1910
1825
|
}
|
|
1911
1826
|
}
|
|
@@ -1951,10 +1866,180 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
|
1951
1866
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1952
1867
|
import { resolve as resolve8 } from "path";
|
|
1953
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
|
|
1954
1952
|
function flag5(args, name) {
|
|
1955
1953
|
const i = args.indexOf(name);
|
|
1956
1954
|
return i >= 0 ? args[i + 1] : void 0;
|
|
1957
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
|
+
}
|
|
1958
2043
|
var TOKEN_FLAGS = {
|
|
1959
2044
|
"--cf-token": "CLOUDFLARE_API_TOKEN",
|
|
1960
2045
|
"--github-token": "GITHUB_TOKEN",
|
|
@@ -1979,6 +2064,16 @@ async function initCommand(args) {
|
|
|
1979
2064
|
}
|
|
1980
2065
|
writeFileSync5(configPath, scaffoldConfig(domain));
|
|
1981
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");
|
|
1982
2077
|
const secrets = [];
|
|
1983
2078
|
for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
|
|
1984
2079
|
const v = flag5(args, f);
|
|
@@ -2012,10 +2107,11 @@ async function initCommand(args) {
|
|
|
2012
2107
|
}
|
|
2013
2108
|
console.log(`
|
|
2014
2109
|
Next:
|
|
2015
|
-
greenlight add <name> --lane
|
|
2016
|
-
greenlight
|
|
2017
|
-
|
|
2018
|
-
|
|
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`);
|
|
2019
2115
|
}
|
|
2020
2116
|
|
|
2021
2117
|
// src/commands/preview.ts
|
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-adapters": "0.2.4",
|
|
35
34
|
"@rtrentjones/greenlight-loop": "0.2.4",
|
|
36
35
|
"@rtrentjones/greenlight-shared": "0.2.4",
|
|
37
|
-
"@rtrentjones/greenlight-verify": "0.2.4"
|
|
36
|
+
"@rtrentjones/greenlight-verify": "0.2.4",
|
|
37
|
+
"@rtrentjones/greenlight-adapters": "0.2.4"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node scripts/copy-assets.mjs && tsup",
|