@rtrentjones/greenlight 0.2.9 → 0.2.11
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 +464 -251
- package/package.json +2 -2
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.11";
|
|
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$/, "");
|
|
@@ -1184,10 +1099,17 @@ function vendorDeps(vendorDir) {
|
|
|
1184
1099
|
}
|
|
1185
1100
|
return out;
|
|
1186
1101
|
}
|
|
1187
|
-
function starterVerifyConfig(lane) {
|
|
1188
|
-
const spec = lane === "mcp" ? "
|
|
1102
|
+
function starterVerifyConfig(lane, target) {
|
|
1103
|
+
const spec = lane === "mcp" ? "mode: 'mcp', expectTools: []" : "mode: 'api', checks: [{ path: '/', status: 200 }]";
|
|
1104
|
+
const logHint = target === "oci" ? "oci logging-search search-logs ... // the instance/container logs" : target === "vercel" ? "vercel logs <deployment-url> --token $VERCEL_API_TOKEN" : "wrangler tail --once // workers observability";
|
|
1189
1105
|
return `// Greenlight verify spec \u2014 edit to assert this tool's real contract.
|
|
1190
|
-
export default
|
|
1106
|
+
export default {
|
|
1107
|
+
${spec},
|
|
1108
|
+
// Telemetry-into-verify: a shell command run ONLY when this report FAILS; its last ~50 lines
|
|
1109
|
+
// attach to the report so the agent/CI sees the "why" in-loop. Best-effort (never fails the
|
|
1110
|
+
// gate). Uncomment + adjust:
|
|
1111
|
+
// logsOnFailure: '${logHint}',
|
|
1112
|
+
};
|
|
1191
1113
|
`;
|
|
1192
1114
|
}
|
|
1193
1115
|
function infraTf(name, domain, lane, target, data, envs, slug) {
|
|
@@ -1379,26 +1301,8 @@ jobs:
|
|
|
1379
1301
|
-F client_payload[sha]=\${{ github.sha }}
|
|
1380
1302
|
`;
|
|
1381
1303
|
}
|
|
1382
|
-
function
|
|
1383
|
-
return `
|
|
1384
|
-
|
|
1385
|
-
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
1386
|
-
on:
|
|
1387
|
-
repository_dispatch:
|
|
1388
|
-
types: [deploy-${name}]
|
|
1389
|
-
workflow_dispatch:
|
|
1390
|
-
|
|
1391
|
-
permissions:
|
|
1392
|
-
contents: read
|
|
1393
|
-
|
|
1394
|
-
jobs:
|
|
1395
|
-
deploy:
|
|
1396
|
-
runs-on: ubuntu-latest
|
|
1397
|
-
steps:
|
|
1398
|
-
- uses: actions/checkout@v4
|
|
1399
|
-
- uses: jdx/mise-action@v2
|
|
1400
|
-
- run: pnpm install --frozen-lockfile
|
|
1401
|
-
- run: pip install --quiet oci-cli
|
|
1304
|
+
function ociDeployAndVerifySteps(name) {
|
|
1305
|
+
return ` - run: pip install --quiet oci-cli
|
|
1402
1306
|
- name: Deploy (resolve instance OCID by name -> restart -> re-pull GHCR image)
|
|
1403
1307
|
env:
|
|
1404
1308
|
# The OCI CLI reuses the SAME TF_VAR_OCI_* secrets the apply uses \u2014 one secret set.
|
|
@@ -1431,7 +1335,33 @@ jobs:
|
|
|
1431
1335
|
# The deploy "succeeds" only if the NEW image is actually serving. verify has a built-in
|
|
1432
1336
|
# readiness wait (re-pull + container start). A failure here fails the job \u2192 the status
|
|
1433
1337
|
# posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
|
|
1434
|
-
run: pnpm exec greenlight verify ${name} --env prod
|
|
1338
|
+
run: pnpm exec greenlight verify ${name} --env prod`;
|
|
1339
|
+
}
|
|
1340
|
+
function deployListenerYml(name, toolRepo) {
|
|
1341
|
+
return `name: greenlight-deploy-${name}
|
|
1342
|
+
|
|
1343
|
+
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
1344
|
+
on:
|
|
1345
|
+
repository_dispatch:
|
|
1346
|
+
types: [deploy-${name}]
|
|
1347
|
+
workflow_dispatch:
|
|
1348
|
+
|
|
1349
|
+
permissions:
|
|
1350
|
+
contents: read
|
|
1351
|
+
|
|
1352
|
+
# Share the self-heal workflow's group so a deploy and a remediation never run at the same time.
|
|
1353
|
+
concurrency:
|
|
1354
|
+
group: deploy-${name}
|
|
1355
|
+
cancel-in-progress: false
|
|
1356
|
+
|
|
1357
|
+
jobs:
|
|
1358
|
+
deploy:
|
|
1359
|
+
runs-on: ubuntu-latest
|
|
1360
|
+
steps:
|
|
1361
|
+
- uses: actions/checkout@v4
|
|
1362
|
+
- uses: jdx/mise-action@v2
|
|
1363
|
+
- run: pnpm install --frozen-lockfile
|
|
1364
|
+
${ociDeployAndVerifySteps(name)}
|
|
1435
1365
|
- name: Report status back to ${toolRepo}
|
|
1436
1366
|
if: \${{ always() && github.event.client_payload.sha != '' }}
|
|
1437
1367
|
env:
|
|
@@ -1445,6 +1375,65 @@ jobs:
|
|
|
1445
1375
|
-f description="\${{ job.status }}"
|
|
1446
1376
|
`;
|
|
1447
1377
|
}
|
|
1378
|
+
function remediateYml(name) {
|
|
1379
|
+
return `name: greenlight-remediate-${name}
|
|
1380
|
+
|
|
1381
|
+
# Auto-heal: the keepalive Worker dispatches remediate-${name} when ${name} (oci) is unreachable.
|
|
1382
|
+
on:
|
|
1383
|
+
repository_dispatch:
|
|
1384
|
+
types: [remediate-${name}]
|
|
1385
|
+
workflow_dispatch:
|
|
1386
|
+
|
|
1387
|
+
permissions:
|
|
1388
|
+
contents: read
|
|
1389
|
+
issues: write
|
|
1390
|
+
|
|
1391
|
+
# Same group as greenlight-deploy-${name}: a self-heal never overlaps a deploy or another heal, and
|
|
1392
|
+
# re-applying an already-healthy instance is idempotent (no diff) \u2014 anti-flap with no extra state.
|
|
1393
|
+
concurrency:
|
|
1394
|
+
group: deploy-${name}
|
|
1395
|
+
cancel-in-progress: false
|
|
1396
|
+
|
|
1397
|
+
jobs:
|
|
1398
|
+
remediate:
|
|
1399
|
+
runs-on: ubuntu-latest
|
|
1400
|
+
steps:
|
|
1401
|
+
- uses: actions/checkout@v4
|
|
1402
|
+
- uses: jdx/mise-action@v2
|
|
1403
|
+
- run: pnpm install --frozen-lockfile
|
|
1404
|
+
- uses: hashicorp/setup-terraform@v3
|
|
1405
|
+
with:
|
|
1406
|
+
terraform_version: '~1.10'
|
|
1407
|
+
terraform_wrapper: false
|
|
1408
|
+
- name: Re-apply the instance (recreate it if OCI idle-reclaimed the Always-Free box)
|
|
1409
|
+
env:
|
|
1410
|
+
TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
|
|
1411
|
+
TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
|
|
1412
|
+
TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
|
|
1413
|
+
TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
|
|
1414
|
+
TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
|
|
1415
|
+
TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
|
|
1416
|
+
TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
|
|
1417
|
+
run: |
|
|
1418
|
+
if [ -z "$TF_TOKEN_app_terraform_io" ]; then
|
|
1419
|
+
echo "::warning::no TF_API_TOKEN \u2014 skipping re-apply; will still attempt a restart below"
|
|
1420
|
+
exit 0
|
|
1421
|
+
fi
|
|
1422
|
+
terraform -chdir=infra init -input=false
|
|
1423
|
+
# -target pulls in the instance's deps (the ${name}_network module) automatically.
|
|
1424
|
+
terraform -chdir=infra apply -input=false -auto-approve -target=module.${name}_instance
|
|
1425
|
+
${ociDeployAndVerifySteps(name)}
|
|
1426
|
+
- name: Escalate if the self-heal failed
|
|
1427
|
+
if: \${{ failure() }}
|
|
1428
|
+
env:
|
|
1429
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1430
|
+
run: |
|
|
1431
|
+
gh issue create --repo \${{ github.repository }} \\
|
|
1432
|
+
--title "remediate-${name}: self-heal FAILED" \\
|
|
1433
|
+
--body "Automatic remediation for ${name} (reason: \${{ github.event.client_payload.reason }}) did not bring prod back. Manual attention needed." \\
|
|
1434
|
+
--label keepalive || true
|
|
1435
|
+
`;
|
|
1436
|
+
}
|
|
1448
1437
|
function verifyWorkflowYml(name) {
|
|
1449
1438
|
return `name: greenlight-verify
|
|
1450
1439
|
|
|
@@ -1490,12 +1479,16 @@ function nextVerifyConfig(name) {
|
|
|
1490
1479
|
// Unit tests belong in this repo's PR CI; to also gate the deploy on them, add
|
|
1491
1480
|
// { mode: 'test', command: 'pnpm test' } + a tolerant deps-install step in greenlight-verify.yml.
|
|
1492
1481
|
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
1482
|
+
// Telemetry-into-verify: on a FAILED report, fetch the Vercel deployment's runtime logs and attach
|
|
1483
|
+
// the tail to the report (best-effort, never fails the gate). Needs VERCEL_API_TOKEN in CI.
|
|
1484
|
+
const logsOnFailure = 'vercel logs "$DEPLOYMENT_URL" --token "$VERCEL_API_TOKEN" 2>&1 || true';
|
|
1493
1485
|
const api = bypass
|
|
1494
1486
|
? {
|
|
1495
1487
|
mode: 'api',
|
|
1496
1488
|
checks: [{ path: '/', status: 200, requestHeaders: { 'x-vercel-protection-bypass': bypass } }],
|
|
1489
|
+
logsOnFailure,
|
|
1497
1490
|
}
|
|
1498
|
-
: { mode: 'api', checks: [{ path: '/', status: 401 }] };
|
|
1491
|
+
: { mode: 'api', checks: [{ path: '/', status: 401 }], logsOnFailure };
|
|
1499
1492
|
const agentWeb = process.env.ANTHROPIC_API_KEY
|
|
1500
1493
|
? [
|
|
1501
1494
|
{
|
|
@@ -1515,12 +1508,12 @@ export default [api, ...agentWeb];
|
|
|
1515
1508
|
`;
|
|
1516
1509
|
}
|
|
1517
1510
|
function writeIfAbsent(path, contents, label) {
|
|
1518
|
-
if (
|
|
1511
|
+
if (existsSync6(path)) {
|
|
1519
1512
|
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
1520
1513
|
return;
|
|
1521
1514
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1515
|
+
mkdirSync3(resolve6(path, ".."), { recursive: true });
|
|
1516
|
+
writeFileSync3(path, contents);
|
|
1524
1517
|
console.log(`\u2714 ${label}`);
|
|
1525
1518
|
}
|
|
1526
1519
|
async function adoptCommand(args) {
|
|
@@ -1556,10 +1549,10 @@ async function adoptWrapper(ctx) {
|
|
|
1556
1549
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1557
1550
|
const cwd = process.cwd();
|
|
1558
1551
|
const toolRel = `tools/${name}`;
|
|
1559
|
-
const dest =
|
|
1552
|
+
const dest = resolve6(cwd, toolRel);
|
|
1560
1553
|
console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
|
|
1561
1554
|
`);
|
|
1562
|
-
if (!
|
|
1555
|
+
if (!existsSync6(dest)) {
|
|
1563
1556
|
try {
|
|
1564
1557
|
execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
|
|
1565
1558
|
console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
|
|
@@ -1584,7 +1577,7 @@ async function adoptWrapper(ctx) {
|
|
|
1584
1577
|
external: true,
|
|
1585
1578
|
adopted: true
|
|
1586
1579
|
});
|
|
1587
|
-
|
|
1580
|
+
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1588
1581
|
console.log(
|
|
1589
1582
|
`\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
|
|
1590
1583
|
);
|
|
@@ -1597,12 +1590,12 @@ async function adoptWrapper(ctx) {
|
|
|
1597
1590
|
if (target !== "vercel") {
|
|
1598
1591
|
writeIfAbsent(
|
|
1599
1592
|
join2(cwd, `verify/${name}.config.ts`),
|
|
1600
|
-
starterVerifyConfig(lane),
|
|
1593
|
+
starterVerifyConfig(lane, target),
|
|
1601
1594
|
`verify/${name}.config.ts`
|
|
1602
1595
|
);
|
|
1603
1596
|
}
|
|
1604
1597
|
const providers = providersForTool({ target, data });
|
|
1605
|
-
if (
|
|
1598
|
+
if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1606
1599
|
console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
|
|
1607
1600
|
}
|
|
1608
1601
|
materializeAgentKit(dest, { target, data });
|
|
@@ -1614,6 +1607,11 @@ async function adoptWrapper(ctx) {
|
|
|
1614
1607
|
deployListenerYml(name, slug),
|
|
1615
1608
|
`.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
|
|
1616
1609
|
);
|
|
1610
|
+
writeIfAbsent(
|
|
1611
|
+
join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
|
|
1612
|
+
remediateYml(name),
|
|
1613
|
+
`.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
|
|
1614
|
+
);
|
|
1617
1615
|
writeIfAbsent(
|
|
1618
1616
|
join2(dest, ".github/workflows/greenlight-build.yml"),
|
|
1619
1617
|
containerBuildYml(name, wrapperSlug),
|
|
@@ -1648,9 +1646,9 @@ Next:
|
|
|
1648
1646
|
}
|
|
1649
1647
|
async function adoptStandalone(ctx) {
|
|
1650
1648
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1651
|
-
const repo =
|
|
1652
|
-
if (!
|
|
1653
|
-
const regVendor =
|
|
1649
|
+
const repo = resolve6(process.cwd(), repoArg);
|
|
1650
|
+
if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
|
|
1651
|
+
const regVendor = resolve6(process.cwd(), "vendor");
|
|
1654
1652
|
const vendor = vendorDeps(regVendor);
|
|
1655
1653
|
if (Object.keys(vendor).length === 0) {
|
|
1656
1654
|
throw new Error(
|
|
@@ -1668,15 +1666,15 @@ async function adoptStandalone(ctx) {
|
|
|
1668
1666
|
);
|
|
1669
1667
|
const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1670
1668
|
const pkgPath = join2(repo, "package.json");
|
|
1671
|
-
const existingPkg =
|
|
1672
|
-
|
|
1669
|
+
const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
|
|
1670
|
+
writeFileSync3(
|
|
1673
1671
|
pkgPath,
|
|
1674
1672
|
`${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
|
|
1675
1673
|
`
|
|
1676
1674
|
);
|
|
1677
1675
|
console.log("\u2714 package.json (merged framework deps + overrides)");
|
|
1678
1676
|
const repoVendor = join2(repo, "vendor");
|
|
1679
|
-
|
|
1677
|
+
mkdirSync3(repoVendor, { recursive: true });
|
|
1680
1678
|
for (const f of readdirSync(regVendor)) {
|
|
1681
1679
|
if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
|
|
1682
1680
|
}
|
|
@@ -1710,7 +1708,7 @@ async function adoptStandalone(ctx) {
|
|
|
1710
1708
|
external: true,
|
|
1711
1709
|
adopted: true
|
|
1712
1710
|
});
|
|
1713
|
-
|
|
1711
|
+
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1714
1712
|
console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
|
|
1715
1713
|
console.log(`
|
|
1716
1714
|
Next (in the adopted repo):
|
|
@@ -1723,20 +1721,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
|
|
|
1723
1721
|
}
|
|
1724
1722
|
function addGreenlightScript(dir) {
|
|
1725
1723
|
const pkgPath = join2(dir, "package.json");
|
|
1726
|
-
if (!
|
|
1724
|
+
if (!existsSync6(pkgPath)) {
|
|
1727
1725
|
console.log(
|
|
1728
1726
|
"\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
|
|
1729
1727
|
);
|
|
1730
1728
|
return;
|
|
1731
1729
|
}
|
|
1732
|
-
const pkg = JSON.parse(
|
|
1730
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1733
1731
|
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
1734
1732
|
if (pkg.scripts.greenlight) {
|
|
1735
1733
|
console.log("\xB7 package.json already has a greenlight script");
|
|
1736
1734
|
return;
|
|
1737
1735
|
}
|
|
1738
1736
|
pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
|
|
1739
|
-
|
|
1737
|
+
writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1740
1738
|
`);
|
|
1741
1739
|
console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
|
|
1742
1740
|
}
|
|
@@ -1879,10 +1877,10 @@ async function deployCommand(args) {
|
|
|
1879
1877
|
}
|
|
1880
1878
|
|
|
1881
1879
|
// src/commands/doctor.ts
|
|
1882
|
-
import { existsSync as
|
|
1880
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1883
1881
|
import { join as join4 } from "path";
|
|
1884
1882
|
function dirCheck(label, dir) {
|
|
1885
|
-
return
|
|
1883
|
+
return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
|
|
1886
1884
|
}
|
|
1887
1885
|
function runDoctor(config, root) {
|
|
1888
1886
|
const checks = [];
|
|
@@ -1904,8 +1902,8 @@ function runDoctor(config, root) {
|
|
|
1904
1902
|
const vc = join4(dir, "verify.config.ts");
|
|
1905
1903
|
checks.push({
|
|
1906
1904
|
name: `${t.name}: verify.config.ts`,
|
|
1907
|
-
status:
|
|
1908
|
-
detail:
|
|
1905
|
+
status: existsSync7(vc) ? "ok" : "warn",
|
|
1906
|
+
detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
|
|
1909
1907
|
});
|
|
1910
1908
|
}
|
|
1911
1909
|
}
|
|
@@ -1951,10 +1949,180 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
|
1951
1949
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1952
1950
|
import { resolve as resolve8 } from "path";
|
|
1953
1951
|
import { createInterface as createInterface3 } from "readline/promises";
|
|
1952
|
+
|
|
1953
|
+
// src/tokens.ts
|
|
1954
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
1955
|
+
import { resolve as resolve7 } from "path";
|
|
1956
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
1957
|
+
var SECRETS_DIR = ".greenlight";
|
|
1958
|
+
var SECRETS_FILE = "secrets.env";
|
|
1959
|
+
function presentEnv(cwd) {
|
|
1960
|
+
const out = {};
|
|
1961
|
+
const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
|
|
1962
|
+
if (existsSync8(p)) {
|
|
1963
|
+
for (const { key, value } of parseSecretsEnv(readFileSync5(p, "utf8"))) out[key] = value;
|
|
1964
|
+
}
|
|
1965
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
1966
|
+
if (v !== void 0 && !(k in out)) out[k] = v;
|
|
1967
|
+
}
|
|
1968
|
+
return out;
|
|
1969
|
+
}
|
|
1970
|
+
function upsertSecret(cwd, key, value) {
|
|
1971
|
+
const dir = resolve7(cwd, SECRETS_DIR);
|
|
1972
|
+
mkdirSync4(dir, { recursive: true });
|
|
1973
|
+
const p = resolve7(dir, SECRETS_FILE);
|
|
1974
|
+
const lines = existsSync8(p) ? readFileSync5(p, "utf8").split("\n") : [];
|
|
1975
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
1976
|
+
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
1977
|
+
else {
|
|
1978
|
+
while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
|
|
1979
|
+
lines.push(`${key}=${value}`);
|
|
1980
|
+
}
|
|
1981
|
+
writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
|
|
1982
|
+
`, { mode: 384 });
|
|
1983
|
+
}
|
|
1984
|
+
async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
1985
|
+
const doVerify = opts.verify !== false;
|
|
1986
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
1987
|
+
const env = presentEnv(cwd);
|
|
1988
|
+
const results = [];
|
|
1989
|
+
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
|
|
1990
|
+
try {
|
|
1991
|
+
for (const spec of tokensForTool(tool)) {
|
|
1992
|
+
let value = env[spec.envVar];
|
|
1993
|
+
if (value) {
|
|
1994
|
+
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
1995
|
+
} else if (rl) {
|
|
1996
|
+
console.log(`
|
|
1997
|
+
${spec.envVar} \u2014 ${spec.label}`);
|
|
1998
|
+
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
1999
|
+
const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
|
|
2000
|
+
if (!entered) {
|
|
2001
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
upsertSecret(cwd, spec.envVar, entered);
|
|
2005
|
+
env[spec.envVar] = entered;
|
|
2006
|
+
value = entered;
|
|
2007
|
+
results.push({ envVar: spec.envVar, outcome: "entered" });
|
|
2008
|
+
} else {
|
|
2009
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
if (value && doVerify && spec.verify) {
|
|
2013
|
+
let check;
|
|
2014
|
+
try {
|
|
2015
|
+
check = await spec.verify(value, env);
|
|
2016
|
+
} catch (e) {
|
|
2017
|
+
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
2018
|
+
}
|
|
2019
|
+
const last = results[results.length - 1];
|
|
2020
|
+
if (last) last.verify = check;
|
|
2021
|
+
if (!check.ok && !spec.optional) {
|
|
2022
|
+
throw new Error(
|
|
2023
|
+
`${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
} finally {
|
|
2029
|
+
rl?.close();
|
|
2030
|
+
}
|
|
2031
|
+
return results;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/commands/init.ts
|
|
1954
2035
|
function flag5(args, name) {
|
|
1955
2036
|
const i = args.indexOf(name);
|
|
1956
2037
|
return i >= 0 ? args[i + 1] : void 0;
|
|
1957
2038
|
}
|
|
2039
|
+
var NPM_DEP = `^${MODULE_REF.replace(/^v/, "")}`;
|
|
2040
|
+
function wrapperPackageJson(name) {
|
|
2041
|
+
return `${JSON.stringify(
|
|
2042
|
+
{
|
|
2043
|
+
name,
|
|
2044
|
+
private: true,
|
|
2045
|
+
type: "module",
|
|
2046
|
+
scripts: { greenlight: "greenlight" },
|
|
2047
|
+
dependencies: { "@rtrentjones/greenlight": NPM_DEP }
|
|
2048
|
+
},
|
|
2049
|
+
null,
|
|
2050
|
+
2
|
|
2051
|
+
)}
|
|
2052
|
+
`;
|
|
2053
|
+
}
|
|
2054
|
+
var WRAPPER_GITIGNORE = `# Greenlight wrapper
|
|
2055
|
+
node_modules/
|
|
2056
|
+
.greenlight/ # gathered tokens \u2014 never committed
|
|
2057
|
+
.terraform/
|
|
2058
|
+
*.tfplan
|
|
2059
|
+
tf.plan
|
|
2060
|
+
dist/
|
|
2061
|
+
`;
|
|
2062
|
+
var WRAPPER_MISE = `[tools]
|
|
2063
|
+
node = "24"
|
|
2064
|
+
pnpm = "10.12.1"
|
|
2065
|
+
`;
|
|
2066
|
+
function wrapperInfraYml() {
|
|
2067
|
+
return `name: infra
|
|
2068
|
+
|
|
2069
|
+
# Apply the wrapper's Terraform on push to main (paths: infra/**). State + locking are in HCP
|
|
2070
|
+
# Terraform (set the cloud{} block in infra/main.tf); the run happens here with provider creds
|
|
2071
|
+
# from GitHub Actions secrets (populate them per tool via \`greenlight secrets gather\`).
|
|
2072
|
+
on:
|
|
2073
|
+
push:
|
|
2074
|
+
branches: [main]
|
|
2075
|
+
paths: ['infra/**']
|
|
2076
|
+
workflow_dispatch:
|
|
2077
|
+
|
|
2078
|
+
permissions:
|
|
2079
|
+
contents: read
|
|
2080
|
+
|
|
2081
|
+
concurrency:
|
|
2082
|
+
group: infra
|
|
2083
|
+
cancel-in-progress: false # never interrupt an in-flight apply
|
|
2084
|
+
|
|
2085
|
+
jobs:
|
|
2086
|
+
apply:
|
|
2087
|
+
runs-on: ubuntu-latest
|
|
2088
|
+
env:
|
|
2089
|
+
TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
|
|
2090
|
+
GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
|
|
2091
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
2092
|
+
TF_VAR_cloudflare_zone_id: \${{ secrets.TF_VAR_CLOUDFLARE_ZONE_ID }}
|
|
2093
|
+
TF_VAR_cloudflare_account_id: \${{ secrets.TF_VAR_CLOUDFLARE_ACCOUNT_ID }}
|
|
2094
|
+
# vercel (target: vercel tools)
|
|
2095
|
+
VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
|
|
2096
|
+
# supabase (data: supabase tools)
|
|
2097
|
+
SUPABASE_ACCESS_TOKEN: \${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
2098
|
+
TF_VAR_supabase_database_password: \${{ secrets.TF_VAR_SUPABASE_DATABASE_PASSWORD }}
|
|
2099
|
+
# oci (target: oci tools) \u2014 VCN/subnet/AD are IaC; only auth (+ optional compartment) here
|
|
2100
|
+
TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
|
|
2101
|
+
TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
|
|
2102
|
+
TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
|
|
2103
|
+
TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
|
|
2104
|
+
TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
|
|
2105
|
+
TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
|
|
2106
|
+
steps:
|
|
2107
|
+
- uses: actions/checkout@v4
|
|
2108
|
+
- uses: hashicorp/setup-terraform@v3
|
|
2109
|
+
with:
|
|
2110
|
+
terraform_version: '~1.10'
|
|
2111
|
+
terraform_wrapper: false
|
|
2112
|
+
- run: terraform -chdir=infra init -input=false
|
|
2113
|
+
- run: terraform -chdir=infra plan -input=false -out=tf.plan
|
|
2114
|
+
- run: terraform -chdir=infra apply -input=false tf.plan
|
|
2115
|
+
`;
|
|
2116
|
+
}
|
|
2117
|
+
function scaffoldIfAbsent(path, contents, label) {
|
|
2118
|
+
if (existsSync9(path)) {
|
|
2119
|
+
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
mkdirSync5(resolve8(path, ".."), { recursive: true });
|
|
2123
|
+
writeFileSync5(path, contents);
|
|
2124
|
+
console.log(`\u2714 wrote ${label}`);
|
|
2125
|
+
}
|
|
1958
2126
|
var TOKEN_FLAGS = {
|
|
1959
2127
|
"--cf-token": "CLOUDFLARE_API_TOKEN",
|
|
1960
2128
|
"--github-token": "GITHUB_TOKEN",
|
|
@@ -1979,6 +2147,16 @@ async function initCommand(args) {
|
|
|
1979
2147
|
}
|
|
1980
2148
|
writeFileSync5(configPath, scaffoldConfig(domain));
|
|
1981
2149
|
console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
|
|
2150
|
+
const repoName = domain.replace(/\./g, "-");
|
|
2151
|
+
scaffoldIfAbsent(
|
|
2152
|
+
resolve8(cwd, ".github/workflows/infra.yml"),
|
|
2153
|
+
wrapperInfraYml(),
|
|
2154
|
+
".github/workflows/infra.yml (HCP-backed terraform apply on push)"
|
|
2155
|
+
);
|
|
2156
|
+
scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
|
|
2157
|
+
scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
|
|
2158
|
+
scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
|
|
2159
|
+
scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
|
|
1982
2160
|
const secrets = [];
|
|
1983
2161
|
for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
|
|
1984
2162
|
const v = flag5(args, f);
|
|
@@ -2012,10 +2190,11 @@ async function initCommand(args) {
|
|
|
2012
2190
|
}
|
|
2013
2191
|
console.log(`
|
|
2014
2192
|
Next:
|
|
2015
|
-
greenlight add <name> --lane
|
|
2016
|
-
greenlight
|
|
2017
|
-
|
|
2018
|
-
|
|
2193
|
+
1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
|
|
2194
|
+
# gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
|
|
2195
|
+
2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state-r2.md
|
|
2196
|
+
3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
|
|
2197
|
+
4. greenlight verify <name> --env prod | greenlight doctor`);
|
|
2019
2198
|
}
|
|
2020
2199
|
|
|
2021
2200
|
// src/commands/preview.ts
|
|
@@ -2024,6 +2203,7 @@ import { resolve as resolve10 } from "path";
|
|
|
2024
2203
|
import { setTimeout as sleep } from "timers/promises";
|
|
2025
2204
|
|
|
2026
2205
|
// src/commands/verify.ts
|
|
2206
|
+
import { spawnSync } from "child_process";
|
|
2027
2207
|
import { resolve as resolve9 } from "path";
|
|
2028
2208
|
function defaultSpec(lane) {
|
|
2029
2209
|
switch (lane) {
|
|
@@ -2043,6 +2223,37 @@ function printReport(report) {
|
|
|
2043
2223
|
}
|
|
2044
2224
|
console.log(`
|
|
2045
2225
|
${report.pass ? "\u2714 PASS" : "\u2718 FAIL"}`);
|
|
2226
|
+
if (!report.pass && report.logs) {
|
|
2227
|
+
console.log(`
|
|
2228
|
+
--- recent logs (${report.mode}) ---
|
|
2229
|
+
${report.logs}
|
|
2230
|
+
--- end logs ---`);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
var LOG_TAIL_LINES = 50;
|
|
2234
|
+
function attachFailureLogs(reports, specs, toolDir) {
|
|
2235
|
+
reports.forEach((report, i) => {
|
|
2236
|
+
if (report.pass) return;
|
|
2237
|
+
const cmd = specs[i]?.logsOnFailure;
|
|
2238
|
+
if (!cmd) {
|
|
2239
|
+
report.logs = "(no logsOnFailure configured for this spec)";
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
try {
|
|
2243
|
+
const res = spawnSync(cmd, {
|
|
2244
|
+
shell: true,
|
|
2245
|
+
cwd: toolDir,
|
|
2246
|
+
timeout: 3e4,
|
|
2247
|
+
encoding: "utf8",
|
|
2248
|
+
maxBuffer: 10 * 1024 * 1024
|
|
2249
|
+
});
|
|
2250
|
+
const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd();
|
|
2251
|
+
const tail = out.split("\n").slice(-LOG_TAIL_LINES).join("\n");
|
|
2252
|
+
report.logs = tail || `(logsOnFailure produced no output${res.error ? `: ${res.error.message}` : ""})`;
|
|
2253
|
+
} catch (e) {
|
|
2254
|
+
report.logs = `(log fetch failed: ${e instanceof Error ? e.message : String(e)})`;
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2046
2257
|
}
|
|
2047
2258
|
function flag6(args, name) {
|
|
2048
2259
|
const i = args.indexOf(name);
|
|
@@ -2061,6 +2272,7 @@ async function verifyCommand(args) {
|
|
|
2061
2272
|
reachableTimeoutMs: waitMs,
|
|
2062
2273
|
toolDir: process.cwd()
|
|
2063
2274
|
});
|
|
2275
|
+
attachFailureLogs(reports2, specs2, process.cwd());
|
|
2064
2276
|
for (const report of reports2) printReport(report);
|
|
2065
2277
|
const pass2 = allPass(reports2);
|
|
2066
2278
|
if (reports2.length > 1)
|
|
@@ -2098,6 +2310,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
|
|
|
2098
2310
|
}
|
|
2099
2311
|
const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
|
|
2100
2312
|
const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
|
|
2313
|
+
attachFailureLogs(reports, specs, toolDir);
|
|
2101
2314
|
for (const report of reports) printReport(report);
|
|
2102
2315
|
const pass = allPass(reports);
|
|
2103
2316
|
if (reports.length > 1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Greenlight CLI — setup and lifecycle for the harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,9 +31,9 @@
|
|
|
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",
|
|
36
|
+
"@rtrentjones/greenlight-adapters": "0.2.4",
|
|
37
37
|
"@rtrentjones/greenlight-verify": "0.2.4"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|