@rtrentjones/greenlight 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/skills/provider-cloudflare/SKILL.md +27 -26
- package/assets/skills/provider-gemini/SKILL.md +36 -50
- package/assets/skills/provider-github/SKILL.md +26 -25
- package/assets/skills/provider-hcp/SKILL.md +17 -18
- package/assets/skills/provider-neon/SKILL.md +28 -32
- package/assets/skills/provider-oci/SKILL.md +42 -53
- package/assets/skills/provider-supabase/SKILL.md +21 -16
- package/assets/skills/provider-vercel/SKILL.md +36 -33
- package/dist/bin.js +483 -321
- package/dist/{chunk-P6FRYOOV.js → chunk-OBWWE7GE.js} +14 -8
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/templates/_template-agent/wrangler.toml +6 -6
package/dist/bin.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
ConfigSchema,
|
|
4
|
+
MATRIX,
|
|
4
5
|
allPass,
|
|
6
|
+
describeMatrix,
|
|
5
7
|
loadConfig,
|
|
6
8
|
resolveUrl,
|
|
7
9
|
scanSqlFiles,
|
|
8
10
|
verifyAll
|
|
9
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-OBWWE7GE.js";
|
|
10
12
|
import "./chunk-HX7VA25D.js";
|
|
11
13
|
import "./chunk-N3IKUCSF.js";
|
|
12
14
|
import "./chunk-KP3Y6WRU.js";
|
|
@@ -62,6 +64,12 @@ jobs:
|
|
|
62
64
|
RUN_TOKEN: \${{ secrets.RUN_TOKEN }}
|
|
63
65
|
run: |
|
|
64
66
|
cd tools/${name}
|
|
67
|
+
# Account id as code: resolve the (non-secret) account id from the domain's zone and inject
|
|
68
|
+
# it into wrangler.toml \u2014 wrangler can't call /memberships to auto-discover it with a scoped
|
|
69
|
+
# token. Derived, so the repo keeps the REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID placeholder.
|
|
70
|
+
ACCT=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=${domain}" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[0].account.id // empty')
|
|
71
|
+
if [ -z "$ACCT" ]; then echo "::error::could not resolve the Cloudflare account id for ${domain} (token needs Zone:Read?)"; exit 1; fi
|
|
72
|
+
sed -i "s/REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID/$ACCT/g" wrangler.toml
|
|
65
73
|
# KV namespace as code: find-or-create the STATE namespace (idempotent), then inject its id
|
|
66
74
|
# into wrangler.toml for this deploy. The id is non-secret + derived, so the repo keeps the
|
|
67
75
|
# REPLACE_WITH_KV_NAMESPACE_ID placeholder \u2014 no manual create, no hardcoded id.
|
|
@@ -93,19 +101,6 @@ jobs:
|
|
|
93
101
|
run: echo "Missing CLOUDFLARE_API_TOKEN or GEMINI_API_KEY \u2014 ${name} deploy skipped."
|
|
94
102
|
`;
|
|
95
103
|
}
|
|
96
|
-
async function resolveCloudflareAccountId(domain, token) {
|
|
97
|
-
try {
|
|
98
|
-
const res = await fetch(
|
|
99
|
-
`https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(domain)}`,
|
|
100
|
-
{ headers: { Authorization: `Bearer ${token}` } }
|
|
101
|
-
);
|
|
102
|
-
if (!res.ok) return null;
|
|
103
|
-
const data = await res.json();
|
|
104
|
-
return data.result?.[0]?.account?.id ?? null;
|
|
105
|
-
} catch {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
104
|
|
|
110
105
|
// src/asset-paths.ts
|
|
111
106
|
import { existsSync } from "fs";
|
|
@@ -340,11 +335,27 @@ var PACKS = [
|
|
|
340
335
|
"Account \xB7 Cloudflare Tunnel \xB7 Edit (only if a tool uses target: oci)"
|
|
341
336
|
],
|
|
342
337
|
verify: async (t) => {
|
|
338
|
+
const auth = { Authorization: `Bearer ${t}` };
|
|
343
339
|
const r = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
344
|
-
headers:
|
|
340
|
+
headers: auth
|
|
345
341
|
});
|
|
346
342
|
const j = await r.json().catch(() => ({}));
|
|
347
|
-
|
|
343
|
+
if (!r.ok || j.result?.status !== "active") {
|
|
344
|
+
return { ok: false, detail: j.result?.status ?? `HTTP ${r.status}` };
|
|
345
|
+
}
|
|
346
|
+
const ar = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=1", {
|
|
347
|
+
headers: auth
|
|
348
|
+
}).catch(() => null);
|
|
349
|
+
if (ar?.ok) {
|
|
350
|
+
const aj = await ar.json().catch(() => ({}));
|
|
351
|
+
if (Array.isArray(aj.result) && aj.result.length === 0) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
detail: "active but no account access \u2014 a Zone-DNS-only token can't resolve account_id; needs Account \xB7 Workers Scripts:Edit + Workers KV Storage:Edit + Account Settings:Read"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { ok: true, detail: "active" };
|
|
348
359
|
}
|
|
349
360
|
}
|
|
350
361
|
],
|
|
@@ -489,7 +500,7 @@ var PACKS = [
|
|
|
489
500
|
id: "github",
|
|
490
501
|
name: "GitHub",
|
|
491
502
|
always: true,
|
|
492
|
-
// secrets
|
|
503
|
+
// the single secret store (Actions secrets) + repo/branch infra
|
|
493
504
|
appliesTo: () => true,
|
|
494
505
|
guide: "docs/provider-tokens.md \u2014 GitHub (gh auth, or a fine-grained PAT)",
|
|
495
506
|
setupUrl: "https://github.com/settings/personal-access-tokens/new",
|
|
@@ -590,7 +601,7 @@ function tokensForTool(tool) {
|
|
|
590
601
|
}
|
|
591
602
|
|
|
592
603
|
// src/version.ts
|
|
593
|
-
var MODULE_REF = "v0.
|
|
604
|
+
var MODULE_REF = "v0.5.0";
|
|
594
605
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
595
606
|
function moduleSource(module, ref = MODULE_REF) {
|
|
596
607
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -915,10 +926,103 @@ function providersForTool(tool) {
|
|
|
915
926
|
return out;
|
|
916
927
|
}
|
|
917
928
|
|
|
929
|
+
// src/commands/agent.ts
|
|
930
|
+
import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
931
|
+
import { resolve as resolve3 } from "path";
|
|
932
|
+
|
|
933
|
+
// src/agent-kit.ts
|
|
934
|
+
function recommendedMcp(tool) {
|
|
935
|
+
return mcpForTool(tool);
|
|
936
|
+
}
|
|
937
|
+
function mergeMcpServers(existing, add) {
|
|
938
|
+
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
939
|
+
for (const [name, val] of Object.entries(add)) {
|
|
940
|
+
if (out.mcpServers[name]) continue;
|
|
941
|
+
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
942
|
+
}
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/commands/agent.ts
|
|
947
|
+
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
948
|
+
|
|
949
|
+
This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
|
|
950
|
+
tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
|
|
951
|
+
branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
|
|
952
|
+
push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
|
|
953
|
+
Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
|
|
954
|
+
pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
|
|
955
|
+
|
|
956
|
+
Agentic kit:
|
|
957
|
+
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
|
|
958
|
+
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
959
|
+
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
960
|
+
- Best-practice skills (one-time, user scope):
|
|
961
|
+
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
962
|
+
`;
|
|
963
|
+
function materializeAgentKit(dir, tool) {
|
|
964
|
+
const src = skillAssetDir();
|
|
965
|
+
if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
|
|
966
|
+
const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
|
|
967
|
+
mkdirSync(dest, { recursive: true });
|
|
968
|
+
cpSync(src, dest, { recursive: true });
|
|
969
|
+
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
970
|
+
for (const pack of packsForTool(tool)) {
|
|
971
|
+
if (!pack.skill) continue;
|
|
972
|
+
const skillSrc = skillAssetDir(pack.skill);
|
|
973
|
+
if (!existsSync3(skillSrc)) continue;
|
|
974
|
+
const skillDest = resolve3(dir, ".claude/skills", pack.skill);
|
|
975
|
+
mkdirSync(skillDest, { recursive: true });
|
|
976
|
+
cpSync(skillSrc, skillDest, { recursive: true });
|
|
977
|
+
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
978
|
+
}
|
|
979
|
+
const mcpPath = resolve3(dir, ".mcp.json");
|
|
980
|
+
const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
|
|
981
|
+
const servers = recommendedMcp(tool);
|
|
982
|
+
writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
983
|
+
`);
|
|
984
|
+
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
985
|
+
const claudePath = resolve3(dir, "CLAUDE.md");
|
|
986
|
+
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
987
|
+
const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
|
|
988
|
+
if (existing.includes(marker)) {
|
|
989
|
+
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
990
|
+
} else {
|
|
991
|
+
writeFileSync(claudePath, existing ? `${existing.trimEnd()}
|
|
992
|
+
|
|
993
|
+
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
994
|
+
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
async function agentCommand(args) {
|
|
998
|
+
if (args[0] !== "sync") {
|
|
999
|
+
console.log(
|
|
1000
|
+
"usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
|
|
1001
|
+
);
|
|
1002
|
+
process.exit(args[0] ? 1 : 0);
|
|
1003
|
+
}
|
|
1004
|
+
const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
|
|
1005
|
+
if (name) {
|
|
1006
|
+
const { config } = await loadManifest();
|
|
1007
|
+
const entry = resolveEntry(config, name);
|
|
1008
|
+
const dir = resolve3(process.cwd(), entry.dir ?? ".");
|
|
1009
|
+
materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
|
|
1010
|
+
console.log(
|
|
1011
|
+
`
|
|
1012
|
+
Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
|
|
1013
|
+
);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
materializeAgentKit(process.cwd());
|
|
1017
|
+
console.log(
|
|
1018
|
+
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
918
1022
|
// src/commands/secrets.ts
|
|
919
1023
|
import { execFileSync } from "child_process";
|
|
920
|
-
import { existsSync as
|
|
921
|
-
import { resolve as
|
|
1024
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
1025
|
+
import { resolve as resolve4 } from "path";
|
|
922
1026
|
import { createInterface } from "readline";
|
|
923
1027
|
function parseOciConfig(text) {
|
|
924
1028
|
const out = {};
|
|
@@ -933,7 +1037,7 @@ function parseOciConfig(text) {
|
|
|
933
1037
|
return out;
|
|
934
1038
|
}
|
|
935
1039
|
function ociPrefill(configPath, keyPath) {
|
|
936
|
-
const cfg = parseOciConfig(
|
|
1040
|
+
const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
|
|
937
1041
|
const map = /* @__PURE__ */ new Map();
|
|
938
1042
|
const set = (k, v) => {
|
|
939
1043
|
if (v) map.set(k, v);
|
|
@@ -943,8 +1047,8 @@ function ociPrefill(configPath, keyPath) {
|
|
|
943
1047
|
set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
|
|
944
1048
|
set("TF_VAR_OCI_REGION", cfg.region);
|
|
945
1049
|
const pem = keyPath ?? cfg.key_file;
|
|
946
|
-
if (pem &&
|
|
947
|
-
map.set("TF_VAR_OCI_PRIVATE_KEY",
|
|
1050
|
+
if (pem && existsSync4(pem)) {
|
|
1051
|
+
map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
|
|
948
1052
|
} else if (pem) {
|
|
949
1053
|
console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
|
|
950
1054
|
}
|
|
@@ -977,11 +1081,11 @@ function hiddenPrompter() {
|
|
|
977
1081
|
if (tty) rl._writeToOutput = () => {
|
|
978
1082
|
};
|
|
979
1083
|
return {
|
|
980
|
-
ask: (query) => new Promise((
|
|
1084
|
+
ask: (query) => new Promise((resolve11) => {
|
|
981
1085
|
process.stdout.write(query);
|
|
982
1086
|
rl.question("", (val) => {
|
|
983
1087
|
process.stdout.write("\n");
|
|
984
|
-
|
|
1088
|
+
resolve11(val.trim());
|
|
985
1089
|
});
|
|
986
1090
|
}),
|
|
987
1091
|
close: () => rl.close()
|
|
@@ -1080,8 +1184,50 @@ async function gatherSecrets(name, repo, env, prefill) {
|
|
|
1080
1184
|
console.log(`
|
|
1081
1185
|
${pushed} secret(s) pushed to ${repo}. (None written to disk.)`);
|
|
1082
1186
|
}
|
|
1187
|
+
async function secretsCheck(name, repo) {
|
|
1188
|
+
const { config } = await loadManifest();
|
|
1189
|
+
const tools = name ? config.tools.filter((t) => t.name === name) : config.tools;
|
|
1190
|
+
if (name && tools.length === 0) throw new Error(`no tool "${name}" in the manifest`);
|
|
1191
|
+
const present = listGitHubSecrets(repo, void 0);
|
|
1192
|
+
console.log(`Secrets check \u2192 ${repo}`);
|
|
1193
|
+
if (!present) console.log("! could not list secrets (gh unauth/no access) \u2014 names only\n");
|
|
1194
|
+
else console.log("");
|
|
1195
|
+
let missing = 0;
|
|
1196
|
+
for (const t of tools) {
|
|
1197
|
+
const expected = /* @__PURE__ */ new Set();
|
|
1198
|
+
for (const pack of packsForTool({ lane: t.lane, target: t.target, data: t.data })) {
|
|
1199
|
+
for (const tok of pack.tokens) {
|
|
1200
|
+
if (!tok.optional) expected.add(secretKeyFor(tok, t.name, t.tokenOverrides));
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (t.lane === "agent") {
|
|
1204
|
+
expected.add("GEMINI_API_KEY");
|
|
1205
|
+
expected.add("RUN_TOKEN");
|
|
1206
|
+
}
|
|
1207
|
+
for (const s of t.tokens ?? []) expected.add(s);
|
|
1208
|
+
console.log(
|
|
1209
|
+
`\u2500\u2500 ${t.name} (${t.lane}/${t.target}${t.data && t.data !== "none" ? `/${t.data}` : ""})`
|
|
1210
|
+
);
|
|
1211
|
+
for (const key of [...expected].sort()) {
|
|
1212
|
+
const have = present ? present.has(key) : void 0;
|
|
1213
|
+
if (have === false) missing++;
|
|
1214
|
+
console.log(` ${have === void 0 ? "?" : have ? "\u2714" : "\u2718"} ${key}`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (present)
|
|
1218
|
+
console.log(`
|
|
1219
|
+
${missing === 0 ? "\u2714 all required secrets present" : `\u2718 ${missing} missing`}`);
|
|
1220
|
+
process.exit(missing > 0 ? 1 : 0);
|
|
1221
|
+
}
|
|
1083
1222
|
async function secretsCommand(args) {
|
|
1084
1223
|
const sub = args[0];
|
|
1224
|
+
if (sub === "check") {
|
|
1225
|
+
const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
|
|
1226
|
+
const repo = flag(args, "--repo") ?? detectRepo(process.cwd());
|
|
1227
|
+
if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
|
|
1228
|
+
await secretsCheck(name, repo);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1085
1231
|
if (sub === "gather") {
|
|
1086
1232
|
const name = args[1];
|
|
1087
1233
|
if (!name || name.startsWith("-")) {
|
|
@@ -1091,7 +1237,7 @@ async function secretsCommand(args) {
|
|
|
1091
1237
|
if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
|
|
1092
1238
|
const ociConfig2 = flag(args, "--oci-config");
|
|
1093
1239
|
const ociKey = flag(args, "--oci-key");
|
|
1094
|
-
const prefill = ociConfig2 ? ociPrefill(
|
|
1240
|
+
const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
|
|
1095
1241
|
await gatherSecrets(name, repo, flag(args, "--env"), prefill);
|
|
1096
1242
|
return;
|
|
1097
1243
|
}
|
|
@@ -1101,161 +1247,6 @@ async function secretsCommand(args) {
|
|
|
1101
1247
|
process.exit(sub ? 1 : 0);
|
|
1102
1248
|
}
|
|
1103
1249
|
|
|
1104
|
-
// src/tokens.ts
|
|
1105
|
-
function presentEnv() {
|
|
1106
|
-
const out = {};
|
|
1107
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
1108
|
-
if (v !== void 0) out[k] = v;
|
|
1109
|
-
}
|
|
1110
|
-
return out;
|
|
1111
|
-
}
|
|
1112
|
-
async function ensureTokensForTool(repo, tool, opts = {}) {
|
|
1113
|
-
const doVerify = opts.verify !== false;
|
|
1114
|
-
const env = presentEnv();
|
|
1115
|
-
const already = listGitHubSecrets(repo, opts.env);
|
|
1116
|
-
const results = [];
|
|
1117
|
-
const prompt = process.stdin.isTTY ? hiddenPrompter() : null;
|
|
1118
|
-
try {
|
|
1119
|
-
for (const spec of tokensForTool(tool)) {
|
|
1120
|
-
const key = secretKeyFor(spec, "", void 0);
|
|
1121
|
-
if (key === "GITHUB_TOKEN") {
|
|
1122
|
-
results.push({ envVar: spec.envVar, outcome: "skipped" });
|
|
1123
|
-
continue;
|
|
1124
|
-
}
|
|
1125
|
-
if (env[spec.envVar] || already?.has(key)) {
|
|
1126
|
-
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
1127
|
-
continue;
|
|
1128
|
-
}
|
|
1129
|
-
if (!prompt) {
|
|
1130
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
1131
|
-
continue;
|
|
1132
|
-
}
|
|
1133
|
-
console.log(`
|
|
1134
|
-
${key} \u2014 ${spec.label}`);
|
|
1135
|
-
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
1136
|
-
const entered = await prompt.ask(
|
|
1137
|
-
` value${spec.optional ? " (optional, Enter to skip)" : ""}: `
|
|
1138
|
-
);
|
|
1139
|
-
if (!entered) {
|
|
1140
|
-
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
1141
|
-
continue;
|
|
1142
|
-
}
|
|
1143
|
-
env[spec.envVar] = entered;
|
|
1144
|
-
let check;
|
|
1145
|
-
if (doVerify && spec.verify) {
|
|
1146
|
-
try {
|
|
1147
|
-
check = await spec.verify(entered, env);
|
|
1148
|
-
} catch (e) {
|
|
1149
|
-
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
1150
|
-
}
|
|
1151
|
-
if (!check.ok && !spec.optional) {
|
|
1152
|
-
throw new Error(
|
|
1153
|
-
`${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
1154
|
-
);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
setGitHubSecret(repo, opts.env, key, entered);
|
|
1158
|
-
results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
|
|
1159
|
-
}
|
|
1160
|
-
} finally {
|
|
1161
|
-
prompt?.close();
|
|
1162
|
-
}
|
|
1163
|
-
return results;
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// src/commands/agent.ts
|
|
1167
|
-
import { cpSync, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1168
|
-
import { resolve as resolve4 } from "path";
|
|
1169
|
-
|
|
1170
|
-
// src/agent-kit.ts
|
|
1171
|
-
function recommendedMcp(tool) {
|
|
1172
|
-
return mcpForTool(tool);
|
|
1173
|
-
}
|
|
1174
|
-
function mergeMcpServers(existing, add) {
|
|
1175
|
-
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
1176
|
-
for (const [name, val] of Object.entries(add)) {
|
|
1177
|
-
if (out.mcpServers[name]) continue;
|
|
1178
|
-
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
1179
|
-
}
|
|
1180
|
-
return out;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
// src/commands/agent.ts
|
|
1184
|
-
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
1185
|
-
|
|
1186
|
-
This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
|
|
1187
|
-
tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
|
|
1188
|
-
branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
|
|
1189
|
-
push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
|
|
1190
|
-
Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
|
|
1191
|
-
pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
|
|
1192
|
-
|
|
1193
|
-
Agentic kit:
|
|
1194
|
-
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
|
|
1195
|
-
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
1196
|
-
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
1197
|
-
- Best-practice skills (one-time, user scope):
|
|
1198
|
-
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
1199
|
-
`;
|
|
1200
|
-
function materializeAgentKit(dir, tool) {
|
|
1201
|
-
const src = skillAssetDir();
|
|
1202
|
-
if (!existsSync4(src)) throw new Error(`skill asset not found at ${src}`);
|
|
1203
|
-
const dest = resolve4(dir, ".claude/skills/deploy-verify-promote");
|
|
1204
|
-
mkdirSync(dest, { recursive: true });
|
|
1205
|
-
cpSync(src, dest, { recursive: true });
|
|
1206
|
-
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
1207
|
-
for (const pack of packsForTool(tool)) {
|
|
1208
|
-
if (!pack.skill) continue;
|
|
1209
|
-
const skillSrc = skillAssetDir(pack.skill);
|
|
1210
|
-
if (!existsSync4(skillSrc)) continue;
|
|
1211
|
-
const skillDest = resolve4(dir, ".claude/skills", pack.skill);
|
|
1212
|
-
mkdirSync(skillDest, { recursive: true });
|
|
1213
|
-
cpSync(skillSrc, skillDest, { recursive: true });
|
|
1214
|
-
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
1215
|
-
}
|
|
1216
|
-
const mcpPath = resolve4(dir, ".mcp.json");
|
|
1217
|
-
const existingMcp = existsSync4(mcpPath) ? JSON.parse(readFileSync2(mcpPath, "utf8")) : null;
|
|
1218
|
-
const servers = recommendedMcp(tool);
|
|
1219
|
-
writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
1220
|
-
`);
|
|
1221
|
-
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
1222
|
-
const claudePath = resolve4(dir, "CLAUDE.md");
|
|
1223
|
-
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
1224
|
-
const existing = existsSync4(claudePath) ? readFileSync2(claudePath, "utf8") : "";
|
|
1225
|
-
if (existing.includes(marker)) {
|
|
1226
|
-
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
1227
|
-
} else {
|
|
1228
|
-
writeFileSync(claudePath, existing ? `${existing.trimEnd()}
|
|
1229
|
-
|
|
1230
|
-
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
1231
|
-
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
async function agentCommand(args) {
|
|
1235
|
-
if (args[0] !== "sync") {
|
|
1236
|
-
console.log(
|
|
1237
|
-
"usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
|
|
1238
|
-
);
|
|
1239
|
-
process.exit(args[0] ? 1 : 0);
|
|
1240
|
-
}
|
|
1241
|
-
const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
|
|
1242
|
-
if (name) {
|
|
1243
|
-
const { config } = await loadManifest();
|
|
1244
|
-
const entry = resolveEntry(config, name);
|
|
1245
|
-
const dir = resolve4(process.cwd(), entry.dir ?? ".");
|
|
1246
|
-
materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
|
|
1247
|
-
console.log(
|
|
1248
|
-
`
|
|
1249
|
-
Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
|
|
1250
|
-
);
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
materializeAgentKit(process.cwd());
|
|
1254
|
-
console.log(
|
|
1255
|
-
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
1256
|
-
);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
1250
|
// src/commands/add.ts
|
|
1260
1251
|
function flag2(args, name) {
|
|
1261
1252
|
const i = args.indexOf(name);
|
|
@@ -1296,9 +1287,25 @@ async function addCommand(args) {
|
|
|
1296
1287
|
"usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod] [--port 8000] [--share <owner>]"
|
|
1297
1288
|
);
|
|
1298
1289
|
}
|
|
1299
|
-
const lane = flag2(args, "--lane");
|
|
1300
|
-
const target = flag2(args, "--target");
|
|
1301
|
-
if (!lane || !target)
|
|
1290
|
+
const lane = flag2(args, "--lane");
|
|
1291
|
+
const target = flag2(args, "--target");
|
|
1292
|
+
if (!lane || !target) {
|
|
1293
|
+
throw new Error(
|
|
1294
|
+
`add needs --lane and --target. Valid combinations:
|
|
1295
|
+
${describeMatrix()}
|
|
1296
|
+
(defaults: next\u2192vercel; astro/mcp/agent\u2192workers)`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
if (!(lane in MATRIX)) {
|
|
1300
|
+
throw new Error(`unknown lane "${lane}". Valid lanes:
|
|
1301
|
+
${describeMatrix()}`);
|
|
1302
|
+
}
|
|
1303
|
+
const rule = MATRIX[lane];
|
|
1304
|
+
if (!rule.targets.includes(target)) {
|
|
1305
|
+
throw new Error(
|
|
1306
|
+
`lane "${lane}" can't target "${target}" \u2014 valid target(s): ${rule.targets.join(" | ")}`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1302
1309
|
const { config, path } = await loadManifest();
|
|
1303
1310
|
if (path.endsWith(".example.ts")) {
|
|
1304
1311
|
throw new Error("no greenlight.config.ts \u2014 run `greenlight init` first");
|
|
@@ -1335,19 +1342,7 @@ async function addCommand(args) {
|
|
|
1335
1342
|
if (existsSync5(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
|
|
1336
1343
|
const wranglerPath = join(dest, "wrangler.toml");
|
|
1337
1344
|
if (existsSync5(wranglerPath)) {
|
|
1338
|
-
|
|
1339
|
-
if (wt.includes("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID")) {
|
|
1340
|
-
const token = presentEnv().CLOUDFLARE_API_TOKEN;
|
|
1341
|
-
const acct = token ? await resolveCloudflareAccountId(config.domain, token) : null;
|
|
1342
|
-
if (acct) {
|
|
1343
|
-
wt = wt.replaceAll("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID", acct);
|
|
1344
|
-
console.log("\u2714 resolved the Cloudflare account id into wrangler.toml");
|
|
1345
|
-
} else {
|
|
1346
|
-
console.log(
|
|
1347
|
-
"\xB7 could not resolve the Cloudflare account id \u2014 set account_id in wrangler.toml"
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1345
|
+
const wt = readFileSync3(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
|
|
1351
1346
|
writeFileSync2(wranglerPath, wt);
|
|
1352
1347
|
}
|
|
1353
1348
|
console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
|
|
@@ -2147,6 +2142,89 @@ function safeGit(cwd, gitArgs) {
|
|
|
2147
2142
|
}
|
|
2148
2143
|
}
|
|
2149
2144
|
|
|
2145
|
+
// src/commands/bump.ts
|
|
2146
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2147
|
+
import { resolve as resolve7 } from "path";
|
|
2148
|
+
|
|
2149
|
+
// src/refs.ts
|
|
2150
|
+
import { readFileSync as readFileSync5, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
2151
|
+
import { join as join3 } from "path";
|
|
2152
|
+
var REF_RE = /greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g;
|
|
2153
|
+
function installedVersion(root) {
|
|
2154
|
+
try {
|
|
2155
|
+
const pkg = JSON.parse(
|
|
2156
|
+
readFileSync5(join3(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
|
|
2157
|
+
);
|
|
2158
|
+
return pkg.version;
|
|
2159
|
+
} catch {
|
|
2160
|
+
return void 0;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
function infraRefs(root) {
|
|
2164
|
+
const refs = /* @__PURE__ */ new Set();
|
|
2165
|
+
try {
|
|
2166
|
+
for (const f of readdirSync2(join3(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
|
|
2167
|
+
for (const m of readFileSync5(join3(root, "infra", f), "utf8").matchAll(REF_RE)) {
|
|
2168
|
+
if (m[1]) refs.add(m[1]);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
} catch {
|
|
2172
|
+
}
|
|
2173
|
+
return [...refs];
|
|
2174
|
+
}
|
|
2175
|
+
function rewriteInfraRefs(root, target) {
|
|
2176
|
+
const want = target.startsWith("v") ? target : `v${target}`;
|
|
2177
|
+
const changed = [];
|
|
2178
|
+
let files;
|
|
2179
|
+
try {
|
|
2180
|
+
files = readdirSync2(join3(root, "infra")).filter((f) => f.endsWith(".tf"));
|
|
2181
|
+
} catch {
|
|
2182
|
+
return changed;
|
|
2183
|
+
}
|
|
2184
|
+
for (const f of files) {
|
|
2185
|
+
const p = join3(root, "infra", f);
|
|
2186
|
+
const body = readFileSync5(p, "utf8");
|
|
2187
|
+
const next = body.replace(
|
|
2188
|
+
/(greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=)v[0-9.]+/g,
|
|
2189
|
+
`$1${want}`
|
|
2190
|
+
);
|
|
2191
|
+
if (next !== body) {
|
|
2192
|
+
writeFileSync4(p, next);
|
|
2193
|
+
changed.push(f);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return changed;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// src/commands/bump.ts
|
|
2200
|
+
function bumpCommand(_args) {
|
|
2201
|
+
const root = process.cwd();
|
|
2202
|
+
const version = installedVersion(root);
|
|
2203
|
+
if (!version) {
|
|
2204
|
+
throw new Error(
|
|
2205
|
+
"no installed @rtrentjones/greenlight here \u2014 run from a consumer wrapper after `pnpm install`"
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
const want = `v${version}`;
|
|
2209
|
+
const before = infraRefs(root);
|
|
2210
|
+
const changed = rewriteInfraRefs(root, version);
|
|
2211
|
+
if (changed.length) {
|
|
2212
|
+
console.log(`\u2714 re-pinned ${changed.length} infra file(s) \u2192 ${want}: ${changed.join(", ")}`);
|
|
2213
|
+
} else {
|
|
2214
|
+
console.log(`\xB7 infra ?ref already ${want}${before.length ? "" : " (no infra pins)"}`);
|
|
2215
|
+
}
|
|
2216
|
+
const pkgPath = resolve7(root, "package.json");
|
|
2217
|
+
if (existsSync7(pkgPath)) {
|
|
2218
|
+
const raw = readFileSync6(pkgPath, "utf8");
|
|
2219
|
+
const next = raw.replace(/("@rtrentjones\/greenlight":\s*")[^"]+(")/, `$1^${version}$2`);
|
|
2220
|
+
if (next !== raw) {
|
|
2221
|
+
writeFileSync5(pkgPath, next);
|
|
2222
|
+
console.log(`\u2714 package.json dep \u2192 ^${version}`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
console.log("\nNext: pnpm install && pnpm greenlight doctor --strict, then commit + push.");
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2150
2228
|
// src/commands/config.ts
|
|
2151
2229
|
import { relative } from "path";
|
|
2152
2230
|
async function configCommand() {
|
|
@@ -2158,7 +2236,7 @@ async function configCommand() {
|
|
|
2158
2236
|
|
|
2159
2237
|
// ../packages/adapters/src/index.ts
|
|
2160
2238
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
2161
|
-
import { join as
|
|
2239
|
+
import { join as join4 } from "path";
|
|
2162
2240
|
function run(cmd, args, cwd, extraEnv) {
|
|
2163
2241
|
execFileSync3(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...extraEnv } });
|
|
2164
2242
|
}
|
|
@@ -2174,7 +2252,7 @@ function workersAdapter(ctx) {
|
|
|
2174
2252
|
siteEnv = void 0;
|
|
2175
2253
|
}
|
|
2176
2254
|
run("pnpm", ["run", "build"], toolDir, siteEnv);
|
|
2177
|
-
return { artifactDir:
|
|
2255
|
+
return { artifactDir: join4(toolDir, "dist") };
|
|
2178
2256
|
},
|
|
2179
2257
|
async deploy(toolDir, env) {
|
|
2180
2258
|
run("pnpm", ["exec", "wrangler", "deploy", "--env", env], toolDir);
|
|
@@ -2276,20 +2354,84 @@ async function deployCommand(args) {
|
|
|
2276
2354
|
// src/commands/doctor.ts
|
|
2277
2355
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
2278
2356
|
import { lookup } from "dns/promises";
|
|
2279
|
-
import { existsSync as
|
|
2280
|
-
import { join as
|
|
2357
|
+
import { existsSync as existsSync9, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
|
|
2358
|
+
import { join as join6 } from "path";
|
|
2359
|
+
|
|
2360
|
+
// src/commands/migrations.ts
|
|
2361
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
|
|
2362
|
+
import { join as join5 } from "path";
|
|
2363
|
+
var DEFAULT_DIR = "supabase/migrations";
|
|
2364
|
+
var CANDIDATE_DIRS = [
|
|
2365
|
+
DEFAULT_DIR,
|
|
2366
|
+
"migrations",
|
|
2367
|
+
"drizzle/migrations",
|
|
2368
|
+
"drizzle",
|
|
2369
|
+
"db/migrations"
|
|
2370
|
+
];
|
|
2371
|
+
function resolveMigrationsDir(explicit, root = process.cwd()) {
|
|
2372
|
+
if (explicit) return explicit;
|
|
2373
|
+
return CANDIDATE_DIRS.find((d) => existsSync8(join5(root, d))) ?? DEFAULT_DIR;
|
|
2374
|
+
}
|
|
2375
|
+
async function migrationsCommand(args) {
|
|
2376
|
+
if (args[0] !== "scan") {
|
|
2377
|
+
console.log(
|
|
2378
|
+
`usage: greenlight migrations scan [<dir>] [--strict]
|
|
2379
|
+
scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
|
|
2380
|
+
no <dir> \u2192 auto-detects ${CANDIDATE_DIRS.join(" | ")}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2381
|
+
);
|
|
2382
|
+
process.exit(args[0] ? 1 : 0);
|
|
2383
|
+
}
|
|
2384
|
+
const dir = resolveMigrationsDir(args.slice(1).find((a) => !a.startsWith("-")));
|
|
2385
|
+
const strict = args.includes("--strict");
|
|
2386
|
+
let names;
|
|
2387
|
+
try {
|
|
2388
|
+
names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
2389
|
+
} catch {
|
|
2390
|
+
console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
|
|
2391
|
+
process.exit(0);
|
|
2392
|
+
}
|
|
2393
|
+
if (names.length === 0) {
|
|
2394
|
+
console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
|
|
2395
|
+
process.exit(0);
|
|
2396
|
+
}
|
|
2397
|
+
const files = names.map((f) => ({
|
|
2398
|
+
path: join5(dir, f),
|
|
2399
|
+
content: readFileSync7(join5(dir, f), "utf8")
|
|
2400
|
+
}));
|
|
2401
|
+
const findings = scanSqlFiles(files);
|
|
2402
|
+
if (findings.length === 0) {
|
|
2403
|
+
console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
|
|
2404
|
+
process.exit(0);
|
|
2405
|
+
}
|
|
2406
|
+
for (const f of findings) {
|
|
2407
|
+
console.log(
|
|
2408
|
+
` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
|
|
2409
|
+
${f.snippet}`
|
|
2410
|
+
);
|
|
2411
|
+
}
|
|
2412
|
+
const dangers = findings.filter((f) => f.severity === "danger");
|
|
2413
|
+
const blocking = strict ? findings : dangers;
|
|
2414
|
+
const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
|
|
2415
|
+
console.log(
|
|
2416
|
+
`
|
|
2417
|
+
${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2418
|
+
);
|
|
2419
|
+
process.exit(blocking.length === 0 ? 0 : 1);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// src/commands/doctor.ts
|
|
2281
2423
|
function dirCheck(label, dir) {
|
|
2282
|
-
return
|
|
2424
|
+
return existsSync9(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
|
|
2283
2425
|
}
|
|
2284
2426
|
function conformanceChecks(t, root) {
|
|
2285
2427
|
const out = [];
|
|
2286
|
-
const toolDir = t.dir ??
|
|
2428
|
+
const toolDir = t.dir ?? join6("tools", t.name);
|
|
2287
2429
|
const specCandidates = t.external ? [
|
|
2288
2430
|
`verify/${t.name}.config.ts`,
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
] : [
|
|
2292
|
-
const found = specCandidates.find((p) =>
|
|
2431
|
+
join6(toolDir, `verify/${t.name}.config.ts`),
|
|
2432
|
+
join6(toolDir, "verify.config.ts")
|
|
2433
|
+
] : [join6(toolDir, "verify.config.ts")];
|
|
2434
|
+
const found = specCandidates.find((p) => existsSync9(join6(root, p)));
|
|
2293
2435
|
out.push({
|
|
2294
2436
|
name: `${t.name}: in the verify loop`,
|
|
2295
2437
|
status: found ? "ok" : "warn",
|
|
@@ -2314,58 +2456,61 @@ function conformanceChecks(t, root) {
|
|
|
2314
2456
|
});
|
|
2315
2457
|
}
|
|
2316
2458
|
if (!t.external && t.lane === "next" && t.target === "vercel") {
|
|
2317
|
-
const wsPath =
|
|
2318
|
-
const ws =
|
|
2459
|
+
const wsPath = join6(root, "pnpm-workspace.yaml");
|
|
2460
|
+
const ws = existsSync9(wsPath) ? readFileSync8(wsPath, "utf8") : "";
|
|
2319
2461
|
const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
|
|
2320
2462
|
out.push({
|
|
2321
2463
|
name: `${t.name}: pnpm workspace member`,
|
|
2322
2464
|
status: member ? "ok" : "warn",
|
|
2323
2465
|
detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
|
|
2324
2466
|
});
|
|
2325
|
-
const hasVercelJson =
|
|
2467
|
+
const hasVercelJson = existsSync9(join6(root, toolDir, "vercel.json"));
|
|
2326
2468
|
out.push({
|
|
2327
2469
|
name: `${t.name}: vercel.json framework`,
|
|
2328
2470
|
status: hasVercelJson ? "ok" : "warn",
|
|
2329
|
-
detail: hasVercelJson ? void 0 : `no ${
|
|
2471
|
+
detail: hasVercelJson ? void 0 : `no ${join6(toolDir, "vercel.json")} (framework: "nextjs") \u2014 Vercel may treat the build as static`
|
|
2330
2472
|
});
|
|
2331
2473
|
}
|
|
2474
|
+
if (t.data === "supabase" || t.data === "neon") {
|
|
2475
|
+
const migBase = join6(root, toolDir);
|
|
2476
|
+
const migDir = resolveMigrationsDir(void 0, migBase);
|
|
2477
|
+
if (existsSync9(join6(migBase, migDir))) {
|
|
2478
|
+
const wired = [join6(migBase, ".github/workflows"), join6(root, ".github/workflows")].some(
|
|
2479
|
+
(d) => {
|
|
2480
|
+
try {
|
|
2481
|
+
return readdirSync4(d).filter((f) => /\.ya?ml$/.test(f)).some((f) => readFileSync8(join6(d, f), "utf8").includes("migrations scan"));
|
|
2482
|
+
} catch {
|
|
2483
|
+
return false;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
);
|
|
2487
|
+
out.push({
|
|
2488
|
+
name: `${t.name}: migrations gate`,
|
|
2489
|
+
status: wired ? "ok" : "warn",
|
|
2490
|
+
detail: wired ? `${migDir} scanned in CI` : `${migDir} present but no workflow runs \`greenlight migrations scan\` \u2014 wire the dangerous-SQL gate before the apply step`
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2332
2494
|
return out;
|
|
2333
2495
|
}
|
|
2334
2496
|
function versionDriftCheck(root) {
|
|
2335
2497
|
const name = "framework version drift";
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
readFileSync5(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
|
|
2340
|
-
);
|
|
2341
|
-
installed = pkg.version;
|
|
2342
|
-
} catch {
|
|
2343
|
-
}
|
|
2344
|
-
const refs = /* @__PURE__ */ new Set();
|
|
2345
|
-
try {
|
|
2346
|
-
for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
|
|
2347
|
-
const body = readFileSync5(join4(root, "infra", f), "utf8");
|
|
2348
|
-
for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
|
|
2349
|
-
if (m[1]) refs.add(m[1]);
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
} catch {
|
|
2353
|
-
}
|
|
2354
|
-
if (!installed && refs.size === 0) {
|
|
2498
|
+
const installed = installedVersion(root);
|
|
2499
|
+
const refList = infraRefs(root);
|
|
2500
|
+
if (!installed && refList.length === 0) {
|
|
2355
2501
|
return {
|
|
2356
2502
|
name,
|
|
2357
2503
|
status: "skip",
|
|
2358
2504
|
detail: "no installed @rtrentjones/greenlight or infra pins here"
|
|
2359
2505
|
};
|
|
2360
2506
|
}
|
|
2361
|
-
const refList = [...refs];
|
|
2362
2507
|
if (installed) {
|
|
2363
2508
|
const want = `v${installed}`;
|
|
2364
2509
|
const bad = refList.filter((r) => r !== want);
|
|
2365
2510
|
return bad.length === 0 ? { name, status: "ok", detail: `infra pins == installed ${want}` } : {
|
|
2366
2511
|
name,
|
|
2367
2512
|
status: "warn",
|
|
2368
|
-
detail: `installed ${want}, but infra pins ${bad.join(", ")} \u2014
|
|
2513
|
+
detail: `installed ${want}, but infra pins ${bad.join(", ")} \u2014 run \`greenlight bump\``
|
|
2369
2514
|
};
|
|
2370
2515
|
}
|
|
2371
2516
|
return refList.length <= 1 ? { name, status: "ok", detail: `infra pins uniform (${refList[0] ?? "none"})` } : { name, status: "warn", detail: `infra ?ref pins not uniform: ${refList.join(", ")}` };
|
|
@@ -2388,7 +2533,7 @@ function submoduleDriftCheck(root) {
|
|
|
2388
2533
|
}
|
|
2389
2534
|
function runDoctor(config, root) {
|
|
2390
2535
|
const checks = [];
|
|
2391
|
-
if (config.blog) checks.push(dirCheck("blog",
|
|
2536
|
+
if (config.blog) checks.push(dirCheck("blog", join6(root, "apps/blog")));
|
|
2392
2537
|
for (const t of config.tools) {
|
|
2393
2538
|
if (t.external) {
|
|
2394
2539
|
const url = resolveUrl({
|
|
@@ -2400,7 +2545,7 @@ function runDoctor(config, root) {
|
|
|
2400
2545
|
checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
|
|
2401
2546
|
if (t.dir) {
|
|
2402
2547
|
checks.push(
|
|
2403
|
-
|
|
2548
|
+
existsSync9(join6(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
|
|
2404
2549
|
name: `${t.name}: dir present`,
|
|
2405
2550
|
status: "warn",
|
|
2406
2551
|
detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
|
|
@@ -2408,7 +2553,7 @@ function runDoctor(config, root) {
|
|
|
2408
2553
|
);
|
|
2409
2554
|
}
|
|
2410
2555
|
} else {
|
|
2411
|
-
checks.push(dirCheck(t.name,
|
|
2556
|
+
checks.push(dirCheck(t.name, join6(root, t.dir ?? join6("tools", t.name))));
|
|
2412
2557
|
}
|
|
2413
2558
|
checks.push(...conformanceChecks(t, root));
|
|
2414
2559
|
}
|
|
@@ -2475,6 +2620,7 @@ async function doctorCommand(args = []) {
|
|
|
2475
2620
|
process.exit(1);
|
|
2476
2621
|
}
|
|
2477
2622
|
const live = args.includes("--live");
|
|
2623
|
+
const strict = args.includes("--strict");
|
|
2478
2624
|
const checks = runDoctor(config, process.cwd());
|
|
2479
2625
|
if (live) {
|
|
2480
2626
|
console.log(" (probing live prod URLs\u2026)");
|
|
@@ -2484,16 +2630,84 @@ async function doctorCommand(args = []) {
|
|
|
2484
2630
|
console.log(` ${ICON[c.status]} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
|
|
2485
2631
|
}
|
|
2486
2632
|
const failed = checks.filter((c) => c.status === "fail").length;
|
|
2487
|
-
|
|
2488
|
-
|
|
2633
|
+
const warned = checks.filter((c) => c.status === "warn").length;
|
|
2634
|
+
console.log(
|
|
2635
|
+
`
|
|
2636
|
+
${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}${warned ? ` \xB7 ${warned} warning(s)` : ""}`
|
|
2637
|
+
);
|
|
2489
2638
|
if (!live) console.log("\xB7 run `greenlight doctor --live` for DNS + reachability probes");
|
|
2490
|
-
|
|
2639
|
+
if (!strict && warned) console.log("\xB7 run `greenlight doctor --strict` to fail on warnings (CI)");
|
|
2640
|
+
process.exit(failed > 0 || strict && warned > 0 ? 1 : 0);
|
|
2491
2641
|
}
|
|
2492
2642
|
|
|
2493
2643
|
// src/commands/init.ts
|
|
2494
|
-
import { existsSync as
|
|
2495
|
-
import { resolve as
|
|
2644
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
2645
|
+
import { resolve as resolve8 } from "path";
|
|
2496
2646
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
2647
|
+
|
|
2648
|
+
// src/tokens.ts
|
|
2649
|
+
function presentEnv() {
|
|
2650
|
+
const out = {};
|
|
2651
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
2652
|
+
if (v !== void 0) out[k] = v;
|
|
2653
|
+
}
|
|
2654
|
+
return out;
|
|
2655
|
+
}
|
|
2656
|
+
async function ensureTokensForTool(repo, tool, opts = {}) {
|
|
2657
|
+
const doVerify = opts.verify !== false;
|
|
2658
|
+
const env = presentEnv();
|
|
2659
|
+
const already = listGitHubSecrets(repo, opts.env);
|
|
2660
|
+
const results = [];
|
|
2661
|
+
const prompt = process.stdin.isTTY ? hiddenPrompter() : null;
|
|
2662
|
+
try {
|
|
2663
|
+
for (const spec of tokensForTool(tool)) {
|
|
2664
|
+
const key = secretKeyFor(spec, "", void 0);
|
|
2665
|
+
if (key === "GITHUB_TOKEN") {
|
|
2666
|
+
results.push({ envVar: spec.envVar, outcome: "skipped" });
|
|
2667
|
+
continue;
|
|
2668
|
+
}
|
|
2669
|
+
if (env[spec.envVar] || already?.has(key)) {
|
|
2670
|
+
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
if (!prompt) {
|
|
2674
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
2677
|
+
console.log(`
|
|
2678
|
+
${key} \u2014 ${spec.label}`);
|
|
2679
|
+
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
2680
|
+
const entered = await prompt.ask(
|
|
2681
|
+
` value${spec.optional ? " (optional, Enter to skip)" : ""}: `
|
|
2682
|
+
);
|
|
2683
|
+
if (!entered) {
|
|
2684
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
env[spec.envVar] = entered;
|
|
2688
|
+
let check;
|
|
2689
|
+
if (doVerify && spec.verify) {
|
|
2690
|
+
try {
|
|
2691
|
+
check = await spec.verify(entered, env);
|
|
2692
|
+
} catch (e) {
|
|
2693
|
+
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
2694
|
+
}
|
|
2695
|
+
if (!check.ok && !spec.optional) {
|
|
2696
|
+
throw new Error(
|
|
2697
|
+
`${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
setGitHubSecret(repo, opts.env, key, entered);
|
|
2702
|
+
results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
|
|
2703
|
+
}
|
|
2704
|
+
} finally {
|
|
2705
|
+
prompt?.close();
|
|
2706
|
+
}
|
|
2707
|
+
return results;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// src/commands/init.ts
|
|
2497
2711
|
function flag5(args, name) {
|
|
2498
2712
|
const i = args.indexOf(name);
|
|
2499
2713
|
return i >= 0 ? args[i + 1] : void 0;
|
|
@@ -2551,8 +2765,9 @@ jobs:
|
|
|
2551
2765
|
TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
|
|
2552
2766
|
GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
|
|
2553
2767
|
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
2554
|
-
|
|
2555
|
-
|
|
2768
|
+
# zone/account ids are enumerable identifiers, not secrets \u2014 repo VARIABLES (vars.*)
|
|
2769
|
+
TF_VAR_cloudflare_zone_id: \${{ vars.CLOUDFLARE_ZONE_ID }}
|
|
2770
|
+
TF_VAR_cloudflare_account_id: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
2556
2771
|
# vercel (target: vercel tools)
|
|
2557
2772
|
VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
|
|
2558
2773
|
# supabase (data: supabase tools)
|
|
@@ -2577,12 +2792,12 @@ jobs:
|
|
|
2577
2792
|
`;
|
|
2578
2793
|
}
|
|
2579
2794
|
function scaffoldIfAbsent(path, contents, label) {
|
|
2580
|
-
if (
|
|
2795
|
+
if (existsSync10(path)) {
|
|
2581
2796
|
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
2582
2797
|
return;
|
|
2583
2798
|
}
|
|
2584
|
-
mkdirSync4(
|
|
2585
|
-
|
|
2799
|
+
mkdirSync4(resolve8(path, ".."), { recursive: true });
|
|
2800
|
+
writeFileSync6(path, contents);
|
|
2586
2801
|
console.log(`\u2714 wrote ${label}`);
|
|
2587
2802
|
}
|
|
2588
2803
|
var TOKEN_FLAGS = {
|
|
@@ -2603,22 +2818,22 @@ async function initCommand(args) {
|
|
|
2603
2818
|
}
|
|
2604
2819
|
if (!domain) throw new Error("a domain is required");
|
|
2605
2820
|
const cwd = process.cwd();
|
|
2606
|
-
const configPath =
|
|
2607
|
-
if (
|
|
2821
|
+
const configPath = resolve8(cwd, "greenlight.config.ts");
|
|
2822
|
+
if (existsSync10(configPath) && !force) {
|
|
2608
2823
|
throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
|
|
2609
2824
|
}
|
|
2610
|
-
|
|
2825
|
+
writeFileSync6(configPath, scaffoldConfig(domain));
|
|
2611
2826
|
console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
|
|
2612
2827
|
const repoName = domain.replace(/\./g, "-");
|
|
2613
2828
|
scaffoldIfAbsent(
|
|
2614
|
-
|
|
2829
|
+
resolve8(cwd, ".github/workflows/infra.yml"),
|
|
2615
2830
|
wrapperInfraYml(),
|
|
2616
2831
|
".github/workflows/infra.yml (HCP-backed terraform apply on push)"
|
|
2617
2832
|
);
|
|
2618
|
-
scaffoldIfAbsent(
|
|
2619
|
-
scaffoldIfAbsent(
|
|
2620
|
-
scaffoldIfAbsent(
|
|
2621
|
-
scaffoldIfAbsent(
|
|
2833
|
+
scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
|
|
2834
|
+
scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
|
|
2835
|
+
scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
|
|
2836
|
+
scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
|
|
2622
2837
|
const repo = flag5(args, "--repo") ?? detectRepo(cwd);
|
|
2623
2838
|
let pushed = 0;
|
|
2624
2839
|
if (repo && !args.includes("--no-push")) {
|
|
@@ -2663,76 +2878,14 @@ Next:
|
|
|
2663
2878
|
4. greenlight verify <name> --env prod | greenlight doctor`);
|
|
2664
2879
|
}
|
|
2665
2880
|
|
|
2666
|
-
// src/commands/migrations.ts
|
|
2667
|
-
import { existsSync as existsSync9, readFileSync as readFileSync6, readdirSync as readdirSync3 } from "fs";
|
|
2668
|
-
import { join as join5 } from "path";
|
|
2669
|
-
var DEFAULT_DIR = "supabase/migrations";
|
|
2670
|
-
var CANDIDATE_DIRS = [
|
|
2671
|
-
DEFAULT_DIR,
|
|
2672
|
-
"migrations",
|
|
2673
|
-
"drizzle/migrations",
|
|
2674
|
-
"drizzle",
|
|
2675
|
-
"db/migrations"
|
|
2676
|
-
];
|
|
2677
|
-
function resolveMigrationsDir(explicit, root = process.cwd()) {
|
|
2678
|
-
if (explicit) return explicit;
|
|
2679
|
-
return CANDIDATE_DIRS.find((d) => existsSync9(join5(root, d))) ?? DEFAULT_DIR;
|
|
2680
|
-
}
|
|
2681
|
-
async function migrationsCommand(args) {
|
|
2682
|
-
if (args[0] !== "scan") {
|
|
2683
|
-
console.log(
|
|
2684
|
-
`usage: greenlight migrations scan [<dir>] [--strict]
|
|
2685
|
-
scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
|
|
2686
|
-
no <dir> \u2192 auto-detects ${CANDIDATE_DIRS.join(" | ")}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2687
|
-
);
|
|
2688
|
-
process.exit(args[0] ? 1 : 0);
|
|
2689
|
-
}
|
|
2690
|
-
const dir = resolveMigrationsDir(args.slice(1).find((a) => !a.startsWith("-")));
|
|
2691
|
-
const strict = args.includes("--strict");
|
|
2692
|
-
let names;
|
|
2693
|
-
try {
|
|
2694
|
-
names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
2695
|
-
} catch {
|
|
2696
|
-
console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
|
|
2697
|
-
process.exit(0);
|
|
2698
|
-
}
|
|
2699
|
-
if (names.length === 0) {
|
|
2700
|
-
console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
|
|
2701
|
-
process.exit(0);
|
|
2702
|
-
}
|
|
2703
|
-
const files = names.map((f) => ({
|
|
2704
|
-
path: join5(dir, f),
|
|
2705
|
-
content: readFileSync6(join5(dir, f), "utf8")
|
|
2706
|
-
}));
|
|
2707
|
-
const findings = scanSqlFiles(files);
|
|
2708
|
-
if (findings.length === 0) {
|
|
2709
|
-
console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
|
|
2710
|
-
process.exit(0);
|
|
2711
|
-
}
|
|
2712
|
-
for (const f of findings) {
|
|
2713
|
-
console.log(
|
|
2714
|
-
` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
|
|
2715
|
-
${f.snippet}`
|
|
2716
|
-
);
|
|
2717
|
-
}
|
|
2718
|
-
const dangers = findings.filter((f) => f.severity === "danger");
|
|
2719
|
-
const blocking = strict ? findings : dangers;
|
|
2720
|
-
const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
|
|
2721
|
-
console.log(
|
|
2722
|
-
`
|
|
2723
|
-
${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2724
|
-
);
|
|
2725
|
-
process.exit(blocking.length === 0 ? 0 : 1);
|
|
2726
|
-
}
|
|
2727
|
-
|
|
2728
2881
|
// src/commands/preview.ts
|
|
2729
2882
|
import { execFileSync as execFileSync5, spawn } from "child_process";
|
|
2730
|
-
import { resolve as
|
|
2883
|
+
import { resolve as resolve10 } from "path";
|
|
2731
2884
|
import { setTimeout as sleep } from "timers/promises";
|
|
2732
2885
|
|
|
2733
2886
|
// src/commands/verify.ts
|
|
2734
2887
|
import { spawnSync } from "child_process";
|
|
2735
|
-
import { resolve as
|
|
2888
|
+
import { resolve as resolve9 } from "path";
|
|
2736
2889
|
function defaultSpec(lane) {
|
|
2737
2890
|
switch (lane) {
|
|
2738
2891
|
case "astro":
|
|
@@ -2860,7 +3013,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
|
|
|
2860
3013
|
if (reachableTimeoutMs > 0) {
|
|
2861
3014
|
console.log(`waiting up to ${reachableTimeoutMs / 1e3}s for ${url} to become reachable\u2026`);
|
|
2862
3015
|
}
|
|
2863
|
-
const toolDir =
|
|
3016
|
+
const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
|
|
2864
3017
|
const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
|
|
2865
3018
|
attachFailureLogs(reports, specs, toolDir);
|
|
2866
3019
|
for (const report of reports) printReport(report);
|
|
@@ -2904,7 +3057,7 @@ async function verifyLocal(entry, url) {
|
|
|
2904
3057
|
process.env.GREENLIGHT_PREVIEW = "1";
|
|
2905
3058
|
process.env.GREENLIGHT_VERIFY_URL = url;
|
|
2906
3059
|
const specs = await loadSpecs(entry);
|
|
2907
|
-
const toolDir =
|
|
3060
|
+
const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
|
|
2908
3061
|
const reports = await verifyAll(url, specs, { toolDir });
|
|
2909
3062
|
for (const report of reports) printReport(report);
|
|
2910
3063
|
return allPass(reports);
|
|
@@ -2915,7 +3068,7 @@ async function previewViaDescriptor(entry, name, portOverride) {
|
|
|
2915
3068
|
const port = portOverride ?? pv.port ?? entry.port ?? lane.port;
|
|
2916
3069
|
const path = pv.path ?? lane.path;
|
|
2917
3070
|
const url = `http://localhost:${port}${path}`;
|
|
2918
|
-
const toolDir =
|
|
3071
|
+
const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
|
|
2919
3072
|
console.log(`preview ${name}: ${pv.command} (\u2192 ${url})`);
|
|
2920
3073
|
const child = spawn(pv.command, {
|
|
2921
3074
|
cwd: toolDir,
|
|
@@ -3221,6 +3374,7 @@ var HELP = `greenlight <command>
|
|
|
3221
3374
|
|
|
3222
3375
|
init --domain <d> [--cf-token ..] [--force] scaffold manifest + secrets, push to GitHub Actions
|
|
3223
3376
|
add <name> --lane <l> --target <t> [..] scaffold a tool from a lane template + manifest entry
|
|
3377
|
+
lanes list the valid lane \xD7 target \xD7 data combinations
|
|
3224
3378
|
config load & validate the manifest, then print it
|
|
3225
3379
|
deploy <name> --env <env> build + deploy an entry via its target adapter
|
|
3226
3380
|
preview <name> [--port <n>] build + serve locally + verify (one command)
|
|
@@ -3228,10 +3382,12 @@ var HELP = `greenlight <command>
|
|
|
3228
3382
|
promote <name> [--perform] [--push] gated develop -> main fast-forward
|
|
3229
3383
|
status <name> last ship/deploy/verify run for a tool (via gh)
|
|
3230
3384
|
secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
|
|
3385
|
+
secrets check [<name>] [--repo o/r] list the GitHub secrets a tool's deploy needs + flag missing
|
|
3231
3386
|
agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
|
|
3232
3387
|
adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
|
|
3233
3388
|
migrations scan [<dir>] [--strict] dangerous-SQL gate for migrations (pre-apply)
|
|
3234
|
-
|
|
3389
|
+
bump re-pin a consumer's infra ?ref + dep to the installed version
|
|
3390
|
+
doctor [--live] [--strict] consistency checks (--live: probes; --strict: fail on warnings)
|
|
3235
3391
|
help show this message
|
|
3236
3392
|
|
|
3237
3393
|
Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see docs/archive/greenlight-v1.md \xA716.`;
|
|
@@ -3248,6 +3404,10 @@ async function main() {
|
|
|
3248
3404
|
return initCommand(args);
|
|
3249
3405
|
case "add":
|
|
3250
3406
|
return addCommand(args);
|
|
3407
|
+
case "lanes":
|
|
3408
|
+
console.log(`Valid lane \xD7 target \xD7 data combinations:
|
|
3409
|
+
${describeMatrix()}`);
|
|
3410
|
+
return;
|
|
3251
3411
|
case "config":
|
|
3252
3412
|
return configCommand();
|
|
3253
3413
|
case "deploy":
|
|
@@ -3268,6 +3428,8 @@ async function main() {
|
|
|
3268
3428
|
return adoptCommand(args);
|
|
3269
3429
|
case "migrations":
|
|
3270
3430
|
return migrationsCommand(args);
|
|
3431
|
+
case "bump":
|
|
3432
|
+
return bumpCommand(args);
|
|
3271
3433
|
case "doctor":
|
|
3272
3434
|
return doctorCommand(args);
|
|
3273
3435
|
default:
|