@rtrentjones/greenlight 0.2.4
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/LICENSE +21 -0
- package/assets/skills/deploy-verify-promote/SKILL.md +53 -0
- package/assets/skills/provider-cloudflare/SKILL.md +42 -0
- package/assets/skills/provider-github/SKILL.md +35 -0
- package/assets/skills/provider-hcp/SKILL.md +46 -0
- package/assets/skills/provider-oci/SKILL.md +58 -0
- package/assets/skills/provider-supabase/SKILL.md +40 -0
- package/assets/skills/provider-vercel/SKILL.md +39 -0
- package/dist/agent-web-I4LXW4SR.js +7 -0
- package/dist/bin.js +1951 -0
- package/dist/chunk-6N7MD6FR.js +75 -0
- package/dist/chunk-KFKYLGFX.js +271 -0
- package/dist/chunk-KP3Y6WRU.js +45 -0
- package/dist/chunk-QFKE5JKC.js +12 -0
- package/dist/chunk-UXHHLEYO.js +231 -0
- package/dist/chunk-WFZTRXBF.js +61 -0
- package/dist/chunk-XBDQJVAX.js +94 -0
- package/dist/eval-LLQPOEQX.js +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +16 -0
- package/dist/mcp-KU7WKB5K.js +7 -0
- package/dist/playwright-CGTTHGIL.js +7 -0
- package/dist/test-7GMOU7I5.js +7 -0
- package/package.json +51 -0
- package/templates/_template-astro/README.md +18 -0
- package/templates/_template-astro/astro.config.mjs +9 -0
- package/templates/_template-astro/package.json +18 -0
- package/templates/_template-astro/src/pages/index.astro +18 -0
- package/templates/_template-astro/tsconfig.json +5 -0
- package/templates/_template-astro/wrangler.jsonc +12 -0
- package/templates/_template-mcp/README.md +28 -0
- package/templates/_template-mcp/oci/Dockerfile +11 -0
- package/templates/_template-mcp/oci/package.json +12 -0
- package/templates/_template-mcp/oci/server.ts +80 -0
- package/templates/_template-mcp/workers/README.md +32 -0
- package/templates/_template-next/README.md +5 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1951 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ConfigSchema,
|
|
4
|
+
allPass,
|
|
5
|
+
loadConfig,
|
|
6
|
+
resolveUrl,
|
|
7
|
+
verifyAll
|
|
8
|
+
} from "./chunk-KFKYLGFX.js";
|
|
9
|
+
import "./chunk-XBDQJVAX.js";
|
|
10
|
+
import "./chunk-WFZTRXBF.js";
|
|
11
|
+
import "./chunk-KP3Y6WRU.js";
|
|
12
|
+
import "./chunk-UXHHLEYO.js";
|
|
13
|
+
import "./chunk-6N7MD6FR.js";
|
|
14
|
+
import "./chunk-QFKE5JKC.js";
|
|
15
|
+
|
|
16
|
+
// src/commands/add.ts
|
|
17
|
+
import { cpSync as cpSync2, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
18
|
+
import { join, resolve as resolve6 } from "path";
|
|
19
|
+
|
|
20
|
+
// src/asset-paths.ts
|
|
21
|
+
import { existsSync } from "fs";
|
|
22
|
+
import { dirname, resolve } from "path";
|
|
23
|
+
import { fileURLToPath } from "url";
|
|
24
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
var packageRoot = resolve(here, "..");
|
|
26
|
+
function templatesRoot() {
|
|
27
|
+
const packaged = resolve(packageRoot, "templates");
|
|
28
|
+
if (existsSync(packaged)) return packaged;
|
|
29
|
+
return resolve(process.cwd(), "tools");
|
|
30
|
+
}
|
|
31
|
+
function skillAssetDir(name = "deploy-verify-promote") {
|
|
32
|
+
const packaged = resolve(packageRoot, "assets", "skills", name);
|
|
33
|
+
if (existsSync(packaged)) return packaged;
|
|
34
|
+
return resolve(packageRoot, "..", ".claude", "skills", name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/config-io.ts
|
|
38
|
+
var q = (s) => `'${s}'`;
|
|
39
|
+
function serializeTool(t) {
|
|
40
|
+
const parts = [
|
|
41
|
+
`name: ${q(t.name)}`,
|
|
42
|
+
`lane: ${q(t.lane)}`,
|
|
43
|
+
`target: ${q(t.target)}`,
|
|
44
|
+
`data: ${q(t.data)}`,
|
|
45
|
+
`auth: ${q(t.auth)}`,
|
|
46
|
+
`access: ${q(t.access)}`,
|
|
47
|
+
`envs: [${t.envs.map(q).join(", ")}]`
|
|
48
|
+
];
|
|
49
|
+
if (t.dir !== void 0) parts.push(`dir: ${q(t.dir)}`);
|
|
50
|
+
if (t.adopted) parts.push("adopted: true");
|
|
51
|
+
if (t.external) parts.push("external: true");
|
|
52
|
+
return ` { ${parts.join(", ")} },`;
|
|
53
|
+
}
|
|
54
|
+
function serializeConfig(c) {
|
|
55
|
+
const tools = c.tools.length ? `
|
|
56
|
+
${c.tools.map(serializeTool).join("\n")}
|
|
57
|
+
` : "";
|
|
58
|
+
const blog = c.blog ? `
|
|
59
|
+
blog: { lane: ${q(c.blog.lane)}, target: ${q(c.blog.target)}, data: ${q(c.blog.data)} },` : "";
|
|
60
|
+
return `import { defineConfig } from '@rtrentjones/greenlight';
|
|
61
|
+
|
|
62
|
+
export default defineConfig({
|
|
63
|
+
domain: ${q(c.domain)},
|
|
64
|
+
alerts: { sink: ${q(c.alerts.sink)} },${blog}
|
|
65
|
+
tools: [${tools}],
|
|
66
|
+
});
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
function scaffoldConfig(domain) {
|
|
70
|
+
return serializeConfig({
|
|
71
|
+
domain,
|
|
72
|
+
alerts: { sink: "github-issue" },
|
|
73
|
+
blog: { lane: "astro", target: "workers", data: "none" },
|
|
74
|
+
tools: []
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function addTool(config, t) {
|
|
78
|
+
if (t.name === "blog" || config.tools.some((x) => x.name === t.name)) {
|
|
79
|
+
throw new Error(`entry "${t.name}" already exists in the manifest`);
|
|
80
|
+
}
|
|
81
|
+
const candidate = {
|
|
82
|
+
...config,
|
|
83
|
+
tools: [
|
|
84
|
+
...config.tools,
|
|
85
|
+
{
|
|
86
|
+
name: t.name,
|
|
87
|
+
lane: t.lane,
|
|
88
|
+
target: t.target,
|
|
89
|
+
data: t.data ?? "none",
|
|
90
|
+
auth: t.auth ?? "none",
|
|
91
|
+
access: t.access ?? "public",
|
|
92
|
+
envs: t.envs ?? ["beta", "prod"],
|
|
93
|
+
...t.dir !== void 0 ? { dir: t.dir } : {},
|
|
94
|
+
...t.adopted ? { adopted: true } : {},
|
|
95
|
+
...t.external ? { external: true } : {}
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
};
|
|
99
|
+
const result = ConfigSchema.safeParse(candidate);
|
|
100
|
+
if (!result.success) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return result.data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/manifest.ts
|
|
109
|
+
import { existsSync as existsSync2 } from "fs";
|
|
110
|
+
import { resolve as resolve2 } from "path";
|
|
111
|
+
import { createJiti } from "jiti";
|
|
112
|
+
function findManifestPath(cwd = process.cwd()) {
|
|
113
|
+
for (const name of ["greenlight.config.ts", "greenlight.config.example.ts"]) {
|
|
114
|
+
const p = resolve2(cwd, name);
|
|
115
|
+
if (existsSync2(p)) return p;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
async function loadManifest(cwd = process.cwd()) {
|
|
120
|
+
const path = findManifestPath(cwd);
|
|
121
|
+
if (!path) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"No greenlight.config.ts (or greenlight.config.example.ts) found in this directory."
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return { path, config: await loadConfig(path) };
|
|
127
|
+
}
|
|
128
|
+
function resolveEntry(config, name) {
|
|
129
|
+
if (name === "blog") {
|
|
130
|
+
if (!config.blog) throw new Error("this manifest has no blog");
|
|
131
|
+
return {
|
|
132
|
+
name: void 0,
|
|
133
|
+
lane: config.blog.lane,
|
|
134
|
+
target: config.blog.target,
|
|
135
|
+
dir: "apps/blog",
|
|
136
|
+
external: false
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const tool = config.tools.find((t) => t.name === name);
|
|
140
|
+
if (!tool) {
|
|
141
|
+
const known = [...config.blog ? ["blog"] : [], ...config.tools.map((t) => t.name)].join(", ");
|
|
142
|
+
throw new Error(`no entry "${name}" in manifest (known: ${known})`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
name: tool.name,
|
|
146
|
+
lane: tool.lane,
|
|
147
|
+
target: tool.target,
|
|
148
|
+
dir: tool.dir ?? `tools/${tool.name}`,
|
|
149
|
+
external: tool.external
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
var VERIFY_MODES = /* @__PURE__ */ new Set(["api", "mcp", "playwright", "test", "agent-web", "eval"]);
|
|
153
|
+
function asSpec(relPath, spec) {
|
|
154
|
+
if (typeof spec?.mode !== "string" || !VERIFY_MODES.has(spec.mode)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`${relPath} must export a spec (or array of specs) with mode ${[...VERIFY_MODES].join("|")}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return spec;
|
|
160
|
+
}
|
|
161
|
+
async function loadVerifySpecAt(relPath) {
|
|
162
|
+
const path = resolve2(process.cwd(), relPath);
|
|
163
|
+
if (!existsSync2(path)) return null;
|
|
164
|
+
const jiti = createJiti(import.meta.url);
|
|
165
|
+
const mod = await jiti.import(path);
|
|
166
|
+
const def = "default" in mod ? mod.default : mod;
|
|
167
|
+
if (Array.isArray(def)) return def.map((s) => asSpec(relPath, s));
|
|
168
|
+
return asSpec(relPath, def);
|
|
169
|
+
}
|
|
170
|
+
function loadVerifySpec(dir) {
|
|
171
|
+
return loadVerifySpecAt(`${dir}/verify.config.ts`);
|
|
172
|
+
}
|
|
173
|
+
function loadExternalVerifySpec(name) {
|
|
174
|
+
return loadVerifySpecAt(`verify/${name}.config.ts`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/providers.ts
|
|
178
|
+
var okStatus = (r) => r.ok;
|
|
179
|
+
var PACKS = [
|
|
180
|
+
{
|
|
181
|
+
id: "cloudflare",
|
|
182
|
+
name: "Cloudflare",
|
|
183
|
+
always: true,
|
|
184
|
+
// the zone/DNS provider + Workers (keepalive) for every Greenlight setup
|
|
185
|
+
appliesTo: () => true,
|
|
186
|
+
guide: "docs/provider-tokens.md \u2014 CLOUDFLARE_API_TOKEN (Workers Scripts:Edit + Zone DNS:Edit)",
|
|
187
|
+
tokens: [
|
|
188
|
+
{
|
|
189
|
+
envVar: "CLOUDFLARE_API_TOKEN",
|
|
190
|
+
label: "API token \u2014 Workers Scripts:Edit + Zone DNS:Edit",
|
|
191
|
+
scopes: [
|
|
192
|
+
"Account \xB7 Workers Scripts \xB7 Edit",
|
|
193
|
+
"Zone \xB7 DNS \xB7 Edit",
|
|
194
|
+
"Account \xB7 Account Settings \xB7 Read"
|
|
195
|
+
],
|
|
196
|
+
verify: async (t) => {
|
|
197
|
+
const r = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
198
|
+
headers: { Authorization: `Bearer ${t}` }
|
|
199
|
+
});
|
|
200
|
+
const j = await r.json().catch(() => ({}));
|
|
201
|
+
return { ok: r.ok && j.result?.status === "active", detail: j.result?.status };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
],
|
|
205
|
+
mcp: {
|
|
206
|
+
cloudflare: { type: "http", url: "https://mcp.cloudflare.com/mcp" },
|
|
207
|
+
"cloudflare-docs": { type: "http", url: "https://docs.mcp.cloudflare.com/mcp" }
|
|
208
|
+
},
|
|
209
|
+
skill: "provider-cloudflare",
|
|
210
|
+
tfModules: ["tool", "keepalive"]
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: "vercel",
|
|
214
|
+
name: "Vercel",
|
|
215
|
+
appliesTo: (t) => t.target === "vercel",
|
|
216
|
+
guide: "docs/provider-tokens.md \u2014 VERCEL_API_TOKEN (team-scoped)",
|
|
217
|
+
tokens: [
|
|
218
|
+
{
|
|
219
|
+
envVar: "VERCEL_API_TOKEN",
|
|
220
|
+
label: "API token (scope to your team)",
|
|
221
|
+
verify: async (t) => {
|
|
222
|
+
const r = await fetch("https://api.vercel.com/v2/user", {
|
|
223
|
+
headers: { Authorization: `Bearer ${t}` }
|
|
224
|
+
});
|
|
225
|
+
return { ok: okStatus(r), detail: `HTTP ${r.status}` };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
mcp: { vercel: { type: "http", url: "https://mcp.vercel.com" } },
|
|
230
|
+
skill: "provider-vercel",
|
|
231
|
+
tfModules: ["vercel"]
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: "supabase",
|
|
235
|
+
name: "Supabase",
|
|
236
|
+
appliesTo: (t) => t.data === "supabase",
|
|
237
|
+
guide: "docs/provider-tokens.md \u2014 SUPABASE_ACCESS_TOKEN (Management API)",
|
|
238
|
+
tokens: [
|
|
239
|
+
{
|
|
240
|
+
envVar: "SUPABASE_ACCESS_TOKEN",
|
|
241
|
+
label: "Management API access token",
|
|
242
|
+
verify: async (t) => {
|
|
243
|
+
const r = await fetch("https://api.supabase.com/v1/projects", {
|
|
244
|
+
headers: { Authorization: `Bearer ${t}` }
|
|
245
|
+
});
|
|
246
|
+
return { ok: okStatus(r), detail: `HTTP ${r.status}` };
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
envVar: "TF_VAR_supabase_database_password",
|
|
251
|
+
label: "database password (ignored when importing an existing project)",
|
|
252
|
+
optional: true
|
|
253
|
+
}
|
|
254
|
+
],
|
|
255
|
+
mcp: {
|
|
256
|
+
supabase: {
|
|
257
|
+
type: "http",
|
|
258
|
+
url: "https://mcp.supabase.com/mcp?project_ref=${SUPABASE_PROJECT_REF}&read_only=true",
|
|
259
|
+
headers: { Authorization: "Bearer ${SUPABASE_ACCESS_TOKEN}" }
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
skill: "provider-supabase",
|
|
263
|
+
tfModules: ["supabase"]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "hcp",
|
|
267
|
+
name: "HCP Terraform (remote state)",
|
|
268
|
+
always: true,
|
|
269
|
+
// remote state backs every wrapper's infra
|
|
270
|
+
appliesTo: () => true,
|
|
271
|
+
guide: "docs/terraform-state-r2.md \u2014 HCP Terraform free tier (no credit card)",
|
|
272
|
+
tokens: [
|
|
273
|
+
{
|
|
274
|
+
envVar: "TF_API_TOKEN",
|
|
275
|
+
label: "HCP Terraform user API token (state backend auth)",
|
|
276
|
+
verify: async (t) => {
|
|
277
|
+
const r = await fetch("https://app.terraform.io/api/v2/organizations", {
|
|
278
|
+
headers: { Authorization: `Bearer ${t}`, "Content-Type": "application/vnd.api+json" }
|
|
279
|
+
});
|
|
280
|
+
return { ok: okStatus(r), detail: `HTTP ${r.status}` };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
],
|
|
284
|
+
skill: "provider-hcp"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "github",
|
|
288
|
+
name: "GitHub",
|
|
289
|
+
always: true,
|
|
290
|
+
// secrets sync + repo/branch infra
|
|
291
|
+
appliesTo: () => true,
|
|
292
|
+
guide: "docs/provider-tokens.md \u2014 GitHub (gh auth, or a fine-grained PAT)",
|
|
293
|
+
tokens: [
|
|
294
|
+
{
|
|
295
|
+
envVar: "GITHUB_TOKEN",
|
|
296
|
+
label: "GitHub token (gh-provided in CI; PAT for cross-repo)",
|
|
297
|
+
optional: true
|
|
298
|
+
// usually provided by `gh` / the Actions built-in token
|
|
299
|
+
}
|
|
300
|
+
],
|
|
301
|
+
skill: "provider-github",
|
|
302
|
+
tfModules: ["repo"]
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: "oci",
|
|
306
|
+
name: "Oracle Cloud (OCI)",
|
|
307
|
+
appliesTo: (t) => t.target === "oci",
|
|
308
|
+
guide: "docs/oci-payg-runbook.md \u2014 Always-Free A1 Container Instance + tunnel (PAYG to stop reclaim)",
|
|
309
|
+
tokens: [
|
|
310
|
+
// OCI provider auth = API-key request signing (no bearer → no cheap fetch verify). These
|
|
311
|
+
// flow to the `oci` Terraform provider as TF_VAR_oci_* (the wrapper apply uses them).
|
|
312
|
+
{ envVar: "TF_VAR_oci_tenancy_ocid", label: "OCI tenancy OCID", optional: true },
|
|
313
|
+
{ envVar: "TF_VAR_oci_user_ocid", label: "OCI user OCID", optional: true },
|
|
314
|
+
{ envVar: "TF_VAR_oci_fingerprint", label: "OCI API key fingerprint", optional: true },
|
|
315
|
+
{
|
|
316
|
+
envVar: "TF_VAR_oci_private_key",
|
|
317
|
+
label: "OCI API private key (PEM content)",
|
|
318
|
+
optional: true
|
|
319
|
+
},
|
|
320
|
+
{ envVar: "TF_VAR_oci_region", label: "OCI region, e.g. us-ashburn-1", optional: true },
|
|
321
|
+
// Container Instance placement (your Always-Free compartment / AD / a public subnet).
|
|
322
|
+
{ envVar: "TF_VAR_oci_compartment_id", label: "OCI compartment OCID", optional: true },
|
|
323
|
+
{
|
|
324
|
+
envVar: "TF_VAR_oci_availability_domain",
|
|
325
|
+
label: "OCI availability domain",
|
|
326
|
+
optional: true
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
envVar: "TF_VAR_oci_subnet_id",
|
|
330
|
+
label: "OCI subnet OCID (public, for egress)",
|
|
331
|
+
optional: true
|
|
332
|
+
},
|
|
333
|
+
// Deploy (restart the instance → re-pull). Set from the Terraform output.
|
|
334
|
+
{
|
|
335
|
+
envVar: "OCI_CONTAINER_INSTANCE_OCID",
|
|
336
|
+
label: "container instance OCID (TF output) \u2014 `greenlight deploy` restarts it",
|
|
337
|
+
optional: true
|
|
338
|
+
}
|
|
339
|
+
],
|
|
340
|
+
skill: "provider-oci",
|
|
341
|
+
tfModules: ["tool", "tunnel", "oci-container-instance"]
|
|
342
|
+
// DNS + tunnel + compute; deploy = restart
|
|
343
|
+
}
|
|
344
|
+
];
|
|
345
|
+
function packsForTool(tool) {
|
|
346
|
+
return PACKS.filter((p) => p.always || (tool ? p.appliesTo(tool) : false));
|
|
347
|
+
}
|
|
348
|
+
function mcpForTool(tool) {
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const pack of packsForTool(tool)) {
|
|
351
|
+
if (pack.mcp) Object.assign(out, pack.mcp);
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
function tokensForTool(tool) {
|
|
356
|
+
const seen = /* @__PURE__ */ new Set();
|
|
357
|
+
const out = [];
|
|
358
|
+
for (const pack of packsForTool(tool)) {
|
|
359
|
+
for (const tok of pack.tokens) {
|
|
360
|
+
if (!seen.has(tok.envVar)) {
|
|
361
|
+
seen.add(tok.envVar);
|
|
362
|
+
out.push(tok);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/version.ts
|
|
370
|
+
var MODULE_REF = "v0.2.4";
|
|
371
|
+
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
372
|
+
function moduleSource(module, ref = MODULE_REF) {
|
|
373
|
+
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/tf-emit.ts
|
|
377
|
+
var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
378
|
+
function emitToolTf(opts) {
|
|
379
|
+
const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
|
|
380
|
+
const slug = opts.slug ?? `OWNER/${name}`;
|
|
381
|
+
const useSupabase = data === "supabase";
|
|
382
|
+
const useVercel = target === "vercel";
|
|
383
|
+
const useOci = target === "oci";
|
|
384
|
+
const envList = envs.map((e) => `"${e}"`).join(", ");
|
|
385
|
+
const blocks = [];
|
|
386
|
+
const assumes = ["var.cloudflare_zone_id"];
|
|
387
|
+
if (useOci)
|
|
388
|
+
assumes.push(
|
|
389
|
+
"var.cloudflare_account_id",
|
|
390
|
+
"var.oci_compartment_id",
|
|
391
|
+
"var.oci_availability_domain",
|
|
392
|
+
"var.oci_subnet_id"
|
|
393
|
+
);
|
|
394
|
+
if (useSupabase) assumes.push("var.supabase_organization_id", "var.supabase_database_password");
|
|
395
|
+
const ghcrOwner = (slug.split("/")[0] ?? "owner").toLowerCase();
|
|
396
|
+
blocks.push(
|
|
397
|
+
`# ${name} \u2014 ${lane}/${target}${useSupabase ? "/supabase" : ""}, emitted by \`greenlight add\`.
|
|
398
|
+
# Review, then commit + push: the wrapper's infra.yml (HCP-backed) runs \`terraform apply\`.
|
|
399
|
+
# Assumes infra/main.tf declares: ${[useVercel && "vercel", useSupabase && "supabase", useOci && "oci"].filter(Boolean).join(" + ") || "cloudflare + github"} provider(s)
|
|
400
|
+
# and the variables ${assumes.join(", ")}.${opts.external ? `
|
|
401
|
+
# External tool: app code + deploy live in ${slug}; this manages only its infra here.` : ""}`
|
|
402
|
+
);
|
|
403
|
+
if (useSupabase) {
|
|
404
|
+
blocks.push(`# One Supabase project (schema-per-env), kept declarative + recreatable + kept alive.
|
|
405
|
+
module "${name}_supabase" {
|
|
406
|
+
source = "${moduleSource("supabase", ref)}"
|
|
407
|
+
|
|
408
|
+
name = "${name}"
|
|
409
|
+
project_name = "${name}-db"
|
|
410
|
+
organization_id = var.supabase_organization_id
|
|
411
|
+
database_password = var.supabase_database_password
|
|
412
|
+
region = "us-east-1"
|
|
413
|
+
}`);
|
|
414
|
+
}
|
|
415
|
+
if (useVercel) {
|
|
416
|
+
const env = useSupabase ? `
|
|
417
|
+
environment = {
|
|
418
|
+
site_url_prod = { key = "SITE_URL", target = ["production"], sensitive = false }
|
|
419
|
+
site_url_beta = { key = "SITE_URL", target = ["preview"], sensitive = false }
|
|
420
|
+
supa_url_prod = { key = "NEXT_PUBLIC_SUPABASE_URL", target = ["production"], sensitive = false }
|
|
421
|
+
supa_anon_prod = { key = "NEXT_PUBLIC_SUPABASE_ANON_KEY", target = ["production"], sensitive = false }
|
|
422
|
+
supa_service_prod = { key = "SUPABASE_SERVICE_ROLE_KEY", target = ["production"], sensitive = true }
|
|
423
|
+
supa_url_beta = { key = "NEXT_PUBLIC_SUPABASE_URL", target = ["preview"], sensitive = false }
|
|
424
|
+
supa_anon_beta = { key = "NEXT_PUBLIC_SUPABASE_ANON_KEY", target = ["preview"], sensitive = false }
|
|
425
|
+
supa_service_beta = { key = "SUPABASE_SERVICE_ROLE_KEY", target = ["preview"], sensitive = true }
|
|
426
|
+
}
|
|
427
|
+
environment_values = {
|
|
428
|
+
site_url_prod = "https://${name}.${domain}"
|
|
429
|
+
site_url_beta = "https://beta.${name}.${domain}"
|
|
430
|
+
supa_url_prod = module.${name}_supabase.url
|
|
431
|
+
supa_anon_prod = module.${name}_supabase.anon_key
|
|
432
|
+
supa_service_prod = module.${name}_supabase.service_role_key
|
|
433
|
+
supa_url_beta = module.${name}_supabase.url
|
|
434
|
+
supa_anon_beta = module.${name}_supabase.anon_key
|
|
435
|
+
supa_service_beta = module.${name}_supabase.service_role_key
|
|
436
|
+
}` : `
|
|
437
|
+
# No managed data store \u2014 add environment/environment_values if the app needs vars.
|
|
438
|
+
environment = {}
|
|
439
|
+
environment_values = {}`;
|
|
440
|
+
blocks.push(`# Configure the EXISTING Vercel project (domains + env vars). Deploys ride git integration.
|
|
441
|
+
module "${name}_vercel" {
|
|
442
|
+
source = "${moduleSource("vercel", ref)}"
|
|
443
|
+
|
|
444
|
+
project_id = var.${name}_vercel_project_id
|
|
445
|
+
name = "${name}"
|
|
446
|
+
domain = "${domain}"
|
|
447
|
+
beta_branch = "develop"
|
|
448
|
+
${env}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
variable "${name}_vercel_project_id" {
|
|
452
|
+
type = string
|
|
453
|
+
description = "Vercel project id for ${name} (prj_\u2026); the project must already exist."
|
|
454
|
+
}`);
|
|
455
|
+
}
|
|
456
|
+
if (useOci) {
|
|
457
|
+
blocks.push(`# OCI Container Instance (Always-Free Ampere A1) running the tool's GHCR image + a cloudflared
|
|
458
|
+
# sidecar; the tunnel routes ${name}.${domain} \u2192 the container at localhost:8000. The tool's OWN
|
|
459
|
+
# CI builds + pushes the image (provider-agnostic); deploy = restart the instance (re-pull).
|
|
460
|
+
# beta would be a second instance + tunnel route \u2014 mind the free 2-OCPU / 12-GB A1 cap.
|
|
461
|
+
module "${name}_tunnel" {
|
|
462
|
+
source = "${moduleSource("tunnel", ref)}"
|
|
463
|
+
|
|
464
|
+
account_id = var.cloudflare_account_id
|
|
465
|
+
name = "${name}-tunnel"
|
|
466
|
+
ingress = [
|
|
467
|
+
{ hostname = "${name}.${domain}", service = "http://localhost:8000" },
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module "${name}_instance" {
|
|
472
|
+
source = "${moduleSource("oci-container-instance", ref)}"
|
|
473
|
+
|
|
474
|
+
name = "${name}"
|
|
475
|
+
compartment_id = var.oci_compartment_id
|
|
476
|
+
availability_domain = var.oci_availability_domain
|
|
477
|
+
subnet_id = var.oci_subnet_id
|
|
478
|
+
image_url = var.${name}_image
|
|
479
|
+
tunnel_token = module.${name}_tunnel.token
|
|
480
|
+
|
|
481
|
+
# Tool runtime env \u2014 fill in (e.g. PORT/listen settings, auth). The container must listen on 8000.
|
|
482
|
+
environment = {}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
variable "${name}_image" {
|
|
486
|
+
type = string
|
|
487
|
+
default = "ghcr.io/${ghcrOwner}/${name}:prod"
|
|
488
|
+
description = "GHCR image for ${name} (built + pushed by ${slug}'s own CI)."
|
|
489
|
+
}`);
|
|
490
|
+
}
|
|
491
|
+
blocks.push(`# Subdomain DNS \u2014 CNAME ${name}/beta.${name} \u2192 ${useVercel ? "cname.vercel-dns.com" : useOci ? "the tunnel" : "the target"}.
|
|
492
|
+
module "${name}_dns" {
|
|
493
|
+
source = "${moduleSource("tool", ref)}"
|
|
494
|
+
|
|
495
|
+
name = "${name}"
|
|
496
|
+
domain = "${domain}"
|
|
497
|
+
zone_id = var.cloudflare_zone_id
|
|
498
|
+
github_repo = "${slug}"
|
|
499
|
+
lane = "${lane}"
|
|
500
|
+
target = "${target}"
|
|
501
|
+
data = "${data}"
|
|
502
|
+
envs = [${envList}]${useOci ? `
|
|
503
|
+
cname_target = module.${name}_tunnel.cname_target` : ""}${opts.external ? "\n # External repo managed elsewhere; no GitHub envs here so CI stays single-repo.\n manage_github_environments = false" : ""}
|
|
504
|
+
}`);
|
|
505
|
+
if (useSupabase) {
|
|
506
|
+
blocks.push(`# Keepalive: add this tool to the aggregated keepalive worker so its Supabase DB
|
|
507
|
+
# never idle-pauses. In infra/keepalive.tf, append to module.keepalive.targets_json:
|
|
508
|
+
# { name = "${name}", env = "prod", url = module.${name}_supabase.url, anonKey = module.${name}_supabase.anon_key }`);
|
|
509
|
+
}
|
|
510
|
+
const outputs = useVercel ? `output "${name}_prod_url" { value = module.${name}_vercel.prod_url }
|
|
511
|
+
output "${name}_beta_url" { value = module.${name}_vercel.beta_url }` : `output "${name}_prod_url" { value = module.${name}_dns.prod_url }`;
|
|
512
|
+
blocks.push(
|
|
513
|
+
useOci ? `${outputs}
|
|
514
|
+
output "${name}_tunnel_token" {
|
|
515
|
+
value = module.${name}_tunnel.token
|
|
516
|
+
sensitive = true
|
|
517
|
+
}
|
|
518
|
+
output "${name}_container_instance_id" {
|
|
519
|
+
value = module.${name}_instance.container_instance_id
|
|
520
|
+
description = "Set as OCI_CONTAINER_INSTANCE_OCID so \`greenlight deploy ${name}\` restarts it."
|
|
521
|
+
}` : outputs
|
|
522
|
+
);
|
|
523
|
+
return `${hcl(blocks.join("\n\n"))}
|
|
524
|
+
`;
|
|
525
|
+
}
|
|
526
|
+
function emitWrapperMainTf(opts) {
|
|
527
|
+
const owner = opts.owner ?? "OWNER";
|
|
528
|
+
const need = new Set(opts.providers);
|
|
529
|
+
const req = [
|
|
530
|
+
' cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.0" }',
|
|
531
|
+
' github = { source = "integrations/github", version = "~> 6.0" }'
|
|
532
|
+
];
|
|
533
|
+
if (need.has("vercel"))
|
|
534
|
+
req.push(' vercel = { source = "vercel/vercel", version = "~> 3.0" }');
|
|
535
|
+
if (need.has("supabase"))
|
|
536
|
+
req.push(' supabase = { source = "supabase/supabase", version = "~> 1.0" }');
|
|
537
|
+
if (need.has("oci")) req.push(' oci = { source = "oracle/oci", version = ">= 5.0" }');
|
|
538
|
+
const providerBlocks = ['provider "cloudflare" {}', `provider "github" { owner = "${owner}" }`];
|
|
539
|
+
if (need.has("vercel")) providerBlocks.push('provider "vercel" {}');
|
|
540
|
+
if (need.has("supabase")) providerBlocks.push('provider "supabase" {}');
|
|
541
|
+
if (need.has("oci")) {
|
|
542
|
+
providerBlocks.push(`provider "oci" {
|
|
543
|
+
tenancy_ocid = var.oci_tenancy_ocid
|
|
544
|
+
user_ocid = var.oci_user_ocid
|
|
545
|
+
fingerprint = var.oci_fingerprint
|
|
546
|
+
private_key = var.oci_private_key
|
|
547
|
+
region = var.oci_region
|
|
548
|
+
}`);
|
|
549
|
+
}
|
|
550
|
+
const vars = ['variable "cloudflare_zone_id" { type = string }'];
|
|
551
|
+
vars.push('variable "cloudflare_account_id" {\n type = string\n default = ""\n}');
|
|
552
|
+
if (need.has("supabase")) {
|
|
553
|
+
vars.push('variable "supabase_organization_id" { type = string }');
|
|
554
|
+
vars.push(
|
|
555
|
+
'variable "supabase_database_password" {\n type = string\n sensitive = true\n default = "import-placeholder" # ignored when importing an existing project\n}'
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (need.has("oci")) {
|
|
559
|
+
vars.push('variable "oci_tenancy_ocid" { type = string }');
|
|
560
|
+
vars.push('variable "oci_user_ocid" { type = string }');
|
|
561
|
+
vars.push('variable "oci_fingerprint" { type = string }');
|
|
562
|
+
vars.push('variable "oci_private_key" {\n type = string\n sensitive = true\n}');
|
|
563
|
+
vars.push('variable "oci_region" { type = string }');
|
|
564
|
+
vars.push('variable "oci_compartment_id" { type = string }');
|
|
565
|
+
vars.push('variable "oci_availability_domain" { type = string }');
|
|
566
|
+
vars.push('variable "oci_subnet_id" { type = string }');
|
|
567
|
+
}
|
|
568
|
+
return `# Wrapper infra (singleton): providers + remote-state backend + shared variables.
|
|
569
|
+
# \`greenlight add\` appends per-tool module blocks as infra/<name>.tf. Apply is CI/CD's job
|
|
570
|
+
# (infra.yml). Fill in the HCP backend below before the first apply (docs/terraform-state-r2.md).
|
|
571
|
+
|
|
572
|
+
terraform {
|
|
573
|
+
required_version = ">= 1.7"
|
|
574
|
+
required_providers {
|
|
575
|
+
${req.join("\n")}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
# Remote state \u2014 HCP Terraform free tier (no credit card). Uncomment + set org/workspace:
|
|
579
|
+
# cloud {
|
|
580
|
+
# organization = "YOUR_ORG"
|
|
581
|
+
# workspaces { name = "${opts.domain.replace(/\./g, "-")}" }
|
|
582
|
+
# }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
${providerBlocks.join("\n")}
|
|
586
|
+
|
|
587
|
+
${vars.join("\n")}
|
|
588
|
+
`;
|
|
589
|
+
}
|
|
590
|
+
function providersForTool(tool) {
|
|
591
|
+
const ids = new Set(packsForTool(tool).map((p) => p.id));
|
|
592
|
+
const out = ["cloudflare", "github"];
|
|
593
|
+
if (ids.has("vercel")) out.push("vercel");
|
|
594
|
+
if (ids.has("supabase")) out.push("supabase");
|
|
595
|
+
if (ids.has("oci")) out.push("oci");
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/tokens.ts
|
|
600
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
601
|
+
import { resolve as resolve4 } from "path";
|
|
602
|
+
import { createInterface } from "readline/promises";
|
|
603
|
+
|
|
604
|
+
// src/commands/secrets.ts
|
|
605
|
+
import { execFileSync } from "child_process";
|
|
606
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
607
|
+
import { resolve as resolve3 } from "path";
|
|
608
|
+
function parseSecretsEnv(text) {
|
|
609
|
+
const out = [];
|
|
610
|
+
for (const raw of text.split("\n")) {
|
|
611
|
+
const line = raw.trim();
|
|
612
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
613
|
+
const eq = line.indexOf("=");
|
|
614
|
+
if (eq <= 0) continue;
|
|
615
|
+
out.push({ key: line.slice(0, eq).trim(), value: line.slice(eq + 1) });
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
function parseRepo(remoteUrl) {
|
|
620
|
+
const m = remoteUrl.trim().match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/);
|
|
621
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
622
|
+
}
|
|
623
|
+
function flag(args, name) {
|
|
624
|
+
const i = args.indexOf(name);
|
|
625
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
626
|
+
}
|
|
627
|
+
function detectRepo(cwd) {
|
|
628
|
+
try {
|
|
629
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
630
|
+
cwd,
|
|
631
|
+
encoding: "utf8",
|
|
632
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
633
|
+
// don't leak git's stderr
|
|
634
|
+
});
|
|
635
|
+
return parseRepo(url);
|
|
636
|
+
} catch {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function syncSecrets(opts) {
|
|
641
|
+
const repo = opts.repo ?? detectRepo(opts.cwd);
|
|
642
|
+
if (!repo) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
"could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
const path = resolve3(opts.cwd, ".greenlight/secrets.env");
|
|
648
|
+
if (!existsSync3(path)) {
|
|
649
|
+
throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
|
|
650
|
+
}
|
|
651
|
+
const entries = parseSecretsEnv(readFileSync(path, "utf8"));
|
|
652
|
+
const target = opts.env ? `env "${opts.env}"` : "repo";
|
|
653
|
+
for (const { key, value } of entries) {
|
|
654
|
+
const ghArgs = ["secret", "set", key, "--repo", repo];
|
|
655
|
+
if (opts.env) ghArgs.push("--env", opts.env);
|
|
656
|
+
try {
|
|
657
|
+
execFileSync("gh", ghArgs, { input: value });
|
|
658
|
+
} catch (e) {
|
|
659
|
+
const err = e;
|
|
660
|
+
if (err.code === "ENOENT") {
|
|
661
|
+
throw new Error("the GitHub CLI `gh` is required \u2014 install it and run `gh auth login`");
|
|
662
|
+
}
|
|
663
|
+
const detail = err.stderr?.toString().trim();
|
|
664
|
+
throw new Error(
|
|
665
|
+
`failed to set ${key}${detail ? `: ${detail}` : " (check `gh auth status`)"}`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
console.log(`\u2714 set ${key} \u2192 ${repo} ${target}`);
|
|
669
|
+
}
|
|
670
|
+
return { repo, count: entries.length };
|
|
671
|
+
}
|
|
672
|
+
async function secretsCommand(args) {
|
|
673
|
+
if (args[0] !== "sync") {
|
|
674
|
+
console.log(
|
|
675
|
+
"usage: greenlight secrets sync [--repo owner/repo] [--env <env>] # push .greenlight/secrets.env to GitHub Actions secrets"
|
|
676
|
+
);
|
|
677
|
+
process.exit(args[0] ? 1 : 0);
|
|
678
|
+
}
|
|
679
|
+
const { count } = syncSecrets({
|
|
680
|
+
cwd: process.cwd(),
|
|
681
|
+
repo: flag(args, "--repo"),
|
|
682
|
+
env: flag(args, "--env")
|
|
683
|
+
});
|
|
684
|
+
if (count === 0) {
|
|
685
|
+
console.log("no secrets to sync");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
console.log(
|
|
689
|
+
`
|
|
690
|
+
${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supported.)`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/tokens.ts
|
|
695
|
+
var SECRETS_DIR = ".greenlight";
|
|
696
|
+
var SECRETS_FILE = "secrets.env";
|
|
697
|
+
function presentEnv(cwd) {
|
|
698
|
+
const out = {};
|
|
699
|
+
const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
|
|
700
|
+
if (existsSync4(p)) {
|
|
701
|
+
for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
|
|
702
|
+
}
|
|
703
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
704
|
+
if (v !== void 0 && !(k in out)) out[k] = v;
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
function upsertSecret(cwd, key, value) {
|
|
709
|
+
const dir = resolve4(cwd, SECRETS_DIR);
|
|
710
|
+
mkdirSync(dir, { recursive: true });
|
|
711
|
+
const p = resolve4(dir, SECRETS_FILE);
|
|
712
|
+
const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
|
|
713
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
714
|
+
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
715
|
+
else {
|
|
716
|
+
while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
|
|
717
|
+
lines.push(`${key}=${value}`);
|
|
718
|
+
}
|
|
719
|
+
writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
|
|
720
|
+
`, { mode: 384 });
|
|
721
|
+
}
|
|
722
|
+
async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
723
|
+
const doVerify = opts.verify !== false;
|
|
724
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
725
|
+
const env = presentEnv(cwd);
|
|
726
|
+
const results = [];
|
|
727
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
728
|
+
try {
|
|
729
|
+
for (const spec of tokensForTool(tool)) {
|
|
730
|
+
let value = env[spec.envVar];
|
|
731
|
+
if (value) {
|
|
732
|
+
results.push({ envVar: spec.envVar, outcome: "present" });
|
|
733
|
+
} else if (rl) {
|
|
734
|
+
console.log(`
|
|
735
|
+
${spec.envVar} \u2014 ${spec.label}`);
|
|
736
|
+
if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
|
|
737
|
+
const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
|
|
738
|
+
if (!entered) {
|
|
739
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
upsertSecret(cwd, spec.envVar, entered);
|
|
743
|
+
env[spec.envVar] = entered;
|
|
744
|
+
value = entered;
|
|
745
|
+
results.push({ envVar: spec.envVar, outcome: "entered" });
|
|
746
|
+
} else {
|
|
747
|
+
results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (value && doVerify && spec.verify) {
|
|
751
|
+
let check;
|
|
752
|
+
try {
|
|
753
|
+
check = await spec.verify(value, env);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
756
|
+
}
|
|
757
|
+
const last = results[results.length - 1];
|
|
758
|
+
if (last) last.verify = check;
|
|
759
|
+
if (!check.ok && !spec.optional) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
} finally {
|
|
767
|
+
rl?.close();
|
|
768
|
+
}
|
|
769
|
+
return results;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/commands/agent.ts
|
|
773
|
+
import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
774
|
+
import { resolve as resolve5 } from "path";
|
|
775
|
+
|
|
776
|
+
// src/agent-kit.ts
|
|
777
|
+
function recommendedMcp(tool) {
|
|
778
|
+
return mcpForTool(tool);
|
|
779
|
+
}
|
|
780
|
+
function mergeMcpServers(existing, add) {
|
|
781
|
+
const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
|
|
782
|
+
for (const [name, val] of Object.entries(add)) {
|
|
783
|
+
if (out.mcpServers[name]) continue;
|
|
784
|
+
out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
|
|
785
|
+
}
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/commands/agent.ts
|
|
790
|
+
var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
|
|
791
|
+
|
|
792
|
+
This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
|
|
793
|
+
branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
|
|
794
|
+
|
|
795
|
+
Agentic kit:
|
|
796
|
+
- Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
|
|
797
|
+
- MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
|
|
798
|
+
Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
|
|
799
|
+
- Best-practice skills (one-time, user scope):
|
|
800
|
+
\`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
|
|
801
|
+
`;
|
|
802
|
+
function materializeAgentKit(dir, tool) {
|
|
803
|
+
const src = skillAssetDir();
|
|
804
|
+
if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
|
|
805
|
+
const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
|
|
806
|
+
mkdirSync2(dest, { recursive: true });
|
|
807
|
+
cpSync(src, dest, { recursive: true });
|
|
808
|
+
console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
|
|
809
|
+
for (const pack of packsForTool(tool)) {
|
|
810
|
+
if (!pack.skill) continue;
|
|
811
|
+
const skillSrc = skillAssetDir(pack.skill);
|
|
812
|
+
if (!existsSync5(skillSrc)) continue;
|
|
813
|
+
const skillDest = resolve5(dir, ".claude/skills", pack.skill);
|
|
814
|
+
mkdirSync2(skillDest, { recursive: true });
|
|
815
|
+
cpSync(skillSrc, skillDest, { recursive: true });
|
|
816
|
+
console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
|
|
817
|
+
}
|
|
818
|
+
const mcpPath = resolve5(dir, ".mcp.json");
|
|
819
|
+
const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
|
|
820
|
+
const servers = recommendedMcp(tool);
|
|
821
|
+
writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
|
|
822
|
+
`);
|
|
823
|
+
console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
|
|
824
|
+
const claudePath = resolve5(dir, "CLAUDE.md");
|
|
825
|
+
const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
|
|
826
|
+
const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
|
|
827
|
+
if (existing.includes(marker)) {
|
|
828
|
+
console.log("\xB7 CLAUDE.md already has the loop block");
|
|
829
|
+
} else {
|
|
830
|
+
writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
|
|
831
|
+
|
|
832
|
+
${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
833
|
+
console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
async function agentCommand(args) {
|
|
837
|
+
if (args[0] !== "sync") {
|
|
838
|
+
console.log(
|
|
839
|
+
"usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
|
|
840
|
+
);
|
|
841
|
+
process.exit(args[0] ? 1 : 0);
|
|
842
|
+
}
|
|
843
|
+
materializeAgentKit(process.cwd());
|
|
844
|
+
console.log(
|
|
845
|
+
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/commands/add.ts
|
|
850
|
+
function flag2(args, name) {
|
|
851
|
+
const i = args.indexOf(name);
|
|
852
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
853
|
+
}
|
|
854
|
+
function templateDir(lane, target) {
|
|
855
|
+
const base = join(templatesRoot(), `_template-${lane}`);
|
|
856
|
+
return lane === "mcp" ? join(base, target) : base;
|
|
857
|
+
}
|
|
858
|
+
async function addCommand(args) {
|
|
859
|
+
const name = args[0];
|
|
860
|
+
if (!name || name.startsWith("-")) {
|
|
861
|
+
throw new Error(
|
|
862
|
+
"usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod]"
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
const lane = flag2(args, "--lane");
|
|
866
|
+
const target = flag2(args, "--target");
|
|
867
|
+
if (!lane || !target) throw new Error("add needs --lane and --target");
|
|
868
|
+
const { config, path } = await loadManifest();
|
|
869
|
+
if (path.endsWith(".example.ts")) {
|
|
870
|
+
throw new Error("no greenlight.config.ts \u2014 run `greenlight init` first");
|
|
871
|
+
}
|
|
872
|
+
const next = addTool(config, {
|
|
873
|
+
name,
|
|
874
|
+
lane,
|
|
875
|
+
target,
|
|
876
|
+
data: flag2(args, "--data"),
|
|
877
|
+
auth: flag2(args, "--auth"),
|
|
878
|
+
envs: flag2(args, "--envs")?.split(",")
|
|
879
|
+
});
|
|
880
|
+
const entry = next.tools.find((t) => t.name === name);
|
|
881
|
+
const data = entry?.data ?? "none";
|
|
882
|
+
const envs = entry?.envs ?? ["beta", "prod"];
|
|
883
|
+
const toolInfo = { target, data };
|
|
884
|
+
const dest = resolve6(process.cwd(), "tools", name);
|
|
885
|
+
if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
|
|
886
|
+
const src = templateDir(lane, target);
|
|
887
|
+
if (existsSync6(src)) {
|
|
888
|
+
cpSync2(src, dest, { recursive: true });
|
|
889
|
+
const pkgPath = join(dest, "package.json");
|
|
890
|
+
if (existsSync6(pkgPath)) {
|
|
891
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
892
|
+
pkg.name = name;
|
|
893
|
+
writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
894
|
+
`);
|
|
895
|
+
}
|
|
896
|
+
console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
|
|
897
|
+
} else {
|
|
898
|
+
console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
|
|
899
|
+
}
|
|
900
|
+
writeFileSync3(path, serializeConfig(next));
|
|
901
|
+
console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
|
|
902
|
+
const cwd = process.cwd();
|
|
903
|
+
const providers = providersForTool(toolInfo);
|
|
904
|
+
const infraDir = resolve6(cwd, "infra");
|
|
905
|
+
const mainTf = join(infraDir, "main.tf");
|
|
906
|
+
if (!existsSync6(mainTf)) {
|
|
907
|
+
mkdirSync3(infraDir, { recursive: true });
|
|
908
|
+
writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
|
|
909
|
+
console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
|
|
910
|
+
} else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
911
|
+
console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
|
|
912
|
+
}
|
|
913
|
+
const toolTf = join(infraDir, `${name}.tf`);
|
|
914
|
+
if (existsSync6(toolTf)) {
|
|
915
|
+
console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
|
|
916
|
+
} else {
|
|
917
|
+
writeFileSync3(toolTf, emitToolTf({ name, domain: config.domain, lane, target, data, envs }));
|
|
918
|
+
console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
|
|
919
|
+
}
|
|
920
|
+
if (!args.includes("--no-tokens")) {
|
|
921
|
+
try {
|
|
922
|
+
const outcomes = await ensureTokensForTool(cwd, toolInfo, {
|
|
923
|
+
verify: !args.includes("--no-verify")
|
|
924
|
+
});
|
|
925
|
+
const missing = outcomes.filter((o) => o.outcome === "missing").map((o) => o.envVar);
|
|
926
|
+
if (missing.length) {
|
|
927
|
+
console.log(
|
|
928
|
+
`! missing token(s): ${missing.join(", ")} \u2014 set in .greenlight/secrets.env, then \`greenlight secrets sync\``
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
} catch (e) {
|
|
932
|
+
console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
materializeAgentKit(cwd, toolInfo);
|
|
936
|
+
console.log(`
|
|
937
|
+
Next:
|
|
938
|
+
review infra/${name}.tf, then commit + push \u2192 CI (infra.yml) runs \`terraform apply\`
|
|
939
|
+
greenlight preview ${name} # local build + serve + verify`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/commands/adopt.ts
|
|
943
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
944
|
+
import { cpSync as cpSync3, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync4 } from "fs";
|
|
945
|
+
import { join as join2, resolve as resolve7 } from "path";
|
|
946
|
+
var REF = MODULE_REF;
|
|
947
|
+
function flag3(args, name) {
|
|
948
|
+
const i = args.indexOf(name);
|
|
949
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
950
|
+
}
|
|
951
|
+
function mergePackageJson(existing, repoName, vendor) {
|
|
952
|
+
const pkg = existing ? { ...existing } : { name: repoName, version: "0.0.0", private: true, type: "module" };
|
|
953
|
+
pkg.dependencies = { ...pkg.dependencies ?? {}, ...vendor };
|
|
954
|
+
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
955
|
+
if (!pkg.scripts.greenlight) pkg.scripts.greenlight = "greenlight";
|
|
956
|
+
pkg.pnpm = { ...pkg.pnpm ?? {}, overrides: { ...pkg.pnpm?.overrides ?? {}, ...vendor } };
|
|
957
|
+
return pkg;
|
|
958
|
+
}
|
|
959
|
+
function vendorDeps(vendorDir) {
|
|
960
|
+
const out = {};
|
|
961
|
+
if (!existsSync7(vendorDir)) return out;
|
|
962
|
+
for (const f of readdirSync(vendorDir)) {
|
|
963
|
+
if (!f.endsWith(".tgz")) continue;
|
|
964
|
+
const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
|
|
965
|
+
out[`@${base.replace("-", "/")}`] = `file:vendor/${f}`;
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
function starterVerifyConfig(lane) {
|
|
970
|
+
const spec = lane === "mcp" ? "{ mode: 'mcp', expectTools: [] }" : "{ mode: 'api', checks: [{ path: '/', status: 200 }] }";
|
|
971
|
+
return `// Greenlight verify spec \u2014 edit to assert this tool's real contract.
|
|
972
|
+
export default ${spec};
|
|
973
|
+
`;
|
|
974
|
+
}
|
|
975
|
+
function infraTf(name, domain, lane, target, data, envs, slug) {
|
|
976
|
+
const owner = slug.includes("/") ? slug.split("/")[0] : "OWNER";
|
|
977
|
+
const repo = slug.includes("/") ? slug.split("/")[1] : "REPO";
|
|
978
|
+
const e = envs.map((x) => `"${x}"`).join(", ");
|
|
979
|
+
return `terraform {
|
|
980
|
+
required_version = ">= 1.7"
|
|
981
|
+
required_providers {
|
|
982
|
+
cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.0" }
|
|
983
|
+
github = { source = "integrations/github", version = "~> 6.0" }
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
provider "cloudflare" {}
|
|
988
|
+
provider "github" { owner = "${owner}" }
|
|
989
|
+
|
|
990
|
+
variable "cloudflare_zone_id" { type = string }
|
|
991
|
+
|
|
992
|
+
module "tool" {
|
|
993
|
+
source = "git::https://github.com/RTrentJones/greenlight.git//infra/modules/tool?ref=${REF}"
|
|
994
|
+
name = "${name}"
|
|
995
|
+
domain = "${domain}"
|
|
996
|
+
zone_id = var.cloudflare_zone_id
|
|
997
|
+
github_repo = "${slug}"
|
|
998
|
+
lane = "${lane}"
|
|
999
|
+
target = "${target}"
|
|
1000
|
+
data = "${data}"
|
|
1001
|
+
envs = [${e}]
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
module "repo" {
|
|
1005
|
+
source = "git::https://github.com/RTrentJones/greenlight.git//infra/modules/repo?ref=${REF}"
|
|
1006
|
+
repository = "${repo}"
|
|
1007
|
+
required_checks = ["deploy"]
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
output "prod_url" { value = module.tool.prod_url }
|
|
1011
|
+
`;
|
|
1012
|
+
}
|
|
1013
|
+
function deployYml(name) {
|
|
1014
|
+
return `name: deploy
|
|
1015
|
+
|
|
1016
|
+
# develop -> beta, main -> prod. Creds-guarded; calls the Greenlight CLI.
|
|
1017
|
+
on:
|
|
1018
|
+
push:
|
|
1019
|
+
branches: [develop, main]
|
|
1020
|
+
|
|
1021
|
+
permissions:
|
|
1022
|
+
contents: read
|
|
1023
|
+
|
|
1024
|
+
concurrency:
|
|
1025
|
+
group: deploy-\${{ github.ref }}
|
|
1026
|
+
cancel-in-progress: false
|
|
1027
|
+
|
|
1028
|
+
jobs:
|
|
1029
|
+
deploy:
|
|
1030
|
+
runs-on: ubuntu-latest
|
|
1031
|
+
steps:
|
|
1032
|
+
- uses: actions/checkout@v4
|
|
1033
|
+
- uses: jdx/mise-action@v2
|
|
1034
|
+
- run: pnpm install --frozen-lockfile
|
|
1035
|
+
- name: Resolve target env
|
|
1036
|
+
id: env
|
|
1037
|
+
run: |
|
|
1038
|
+
if [ "\${{ github.ref }}" = "refs/heads/main" ]; then
|
|
1039
|
+
echo "env=prod" >> "$GITHUB_OUTPUT"
|
|
1040
|
+
else
|
|
1041
|
+
echo "env=beta" >> "$GITHUB_OUTPUT"
|
|
1042
|
+
fi
|
|
1043
|
+
- name: Check Cloudflare creds
|
|
1044
|
+
id: creds
|
|
1045
|
+
env:
|
|
1046
|
+
CF: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1047
|
+
run: if [ -n "$CF" ]; then echo "have=1" >> "$GITHUB_OUTPUT"; else echo "have=0" >> "$GITHUB_OUTPUT"; fi
|
|
1048
|
+
- name: Deploy + verify
|
|
1049
|
+
if: steps.creds.outputs.have == '1'
|
|
1050
|
+
env:
|
|
1051
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1052
|
+
run: |
|
|
1053
|
+
pnpm exec greenlight deploy ${name} --env "\${{ steps.env.outputs.env }}"
|
|
1054
|
+
pnpm exec greenlight verify ${name} --env "\${{ steps.env.outputs.env }}"
|
|
1055
|
+
- name: Skip notice
|
|
1056
|
+
if: steps.creds.outputs.have != '1'
|
|
1057
|
+
run: echo "No CLOUDFLARE_API_TOKEN secret \u2014 deploy/verify skipped."
|
|
1058
|
+
`;
|
|
1059
|
+
}
|
|
1060
|
+
function promoteYml(name) {
|
|
1061
|
+
return `name: promote
|
|
1062
|
+
|
|
1063
|
+
# Gated develop -> main fast-forward: verify beta -> FF -> deploy + verify prod.
|
|
1064
|
+
on:
|
|
1065
|
+
workflow_dispatch:
|
|
1066
|
+
|
|
1067
|
+
permissions:
|
|
1068
|
+
contents: write
|
|
1069
|
+
|
|
1070
|
+
jobs:
|
|
1071
|
+
promote:
|
|
1072
|
+
runs-on: ubuntu-latest
|
|
1073
|
+
steps:
|
|
1074
|
+
- uses: actions/checkout@v4
|
|
1075
|
+
with:
|
|
1076
|
+
fetch-depth: 0
|
|
1077
|
+
- uses: jdx/mise-action@v2
|
|
1078
|
+
- run: pnpm install --frozen-lockfile
|
|
1079
|
+
- run: git fetch --no-tags origin main develop
|
|
1080
|
+
- name: Check Cloudflare creds
|
|
1081
|
+
id: creds
|
|
1082
|
+
env:
|
|
1083
|
+
CF: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1084
|
+
run: if [ -n "$CF" ]; then echo "have=1" >> "$GITHUB_OUTPUT"; else echo "have=0" >> "$GITHUB_OUTPUT"; fi
|
|
1085
|
+
- name: Verify beta (gate)
|
|
1086
|
+
if: steps.creds.outputs.have == '1'
|
|
1087
|
+
env:
|
|
1088
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1089
|
+
run: pnpm exec greenlight verify ${name} --env beta
|
|
1090
|
+
- name: Promote (gated fast-forward)
|
|
1091
|
+
run: |
|
|
1092
|
+
git config user.name "github-actions[bot]"
|
|
1093
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
1094
|
+
pnpm exec greenlight promote ${name} --perform --push
|
|
1095
|
+
- name: Deploy + verify prod
|
|
1096
|
+
if: steps.creds.outputs.have == '1'
|
|
1097
|
+
env:
|
|
1098
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1099
|
+
run: |
|
|
1100
|
+
pnpm exec greenlight deploy ${name} --env prod
|
|
1101
|
+
pnpm exec greenlight verify ${name} --env prod
|
|
1102
|
+
`;
|
|
1103
|
+
}
|
|
1104
|
+
var MISE_TOML = `# Toolchain, managed by mise. \`mise install\` to set up.
|
|
1105
|
+
[tools]
|
|
1106
|
+
node = "24"
|
|
1107
|
+
pnpm = "10.12.1"
|
|
1108
|
+
`;
|
|
1109
|
+
function containerBuildYml(name, wrapperRepo) {
|
|
1110
|
+
return `name: greenlight-build
|
|
1111
|
+
|
|
1112
|
+
# Provider-agnostic: build the container -> push to GHCR -> notify the wrapper to deploy.
|
|
1113
|
+
# The wrapper owns the OCI infra + creds; this repo only needs GREENLIGHT_DISPATCH_TOKEN
|
|
1114
|
+
# (a fine-grained PAT with "Contents: write" on ${wrapperRepo}) to fire the dispatch.
|
|
1115
|
+
on:
|
|
1116
|
+
push:
|
|
1117
|
+
branches: [main]
|
|
1118
|
+
workflow_dispatch:
|
|
1119
|
+
|
|
1120
|
+
permissions:
|
|
1121
|
+
contents: read
|
|
1122
|
+
packages: write
|
|
1123
|
+
|
|
1124
|
+
concurrency:
|
|
1125
|
+
group: build-\${{ github.ref }}
|
|
1126
|
+
cancel-in-progress: true
|
|
1127
|
+
|
|
1128
|
+
jobs:
|
|
1129
|
+
build:
|
|
1130
|
+
runs-on: ubuntu-latest
|
|
1131
|
+
steps:
|
|
1132
|
+
- uses: actions/checkout@v4
|
|
1133
|
+
- name: Resolve image ref (GHCR namespaces are lowercase)
|
|
1134
|
+
id: img
|
|
1135
|
+
run: echo "ref=ghcr.io/\${GITHUB_REPOSITORY_OWNER,,}/${name}:prod" >> "$GITHUB_OUTPUT"
|
|
1136
|
+
- uses: docker/setup-qemu-action@v3
|
|
1137
|
+
- uses: docker/setup-buildx-action@v3
|
|
1138
|
+
- uses: docker/login-action@v3
|
|
1139
|
+
with:
|
|
1140
|
+
registry: ghcr.io
|
|
1141
|
+
username: \${{ github.actor }}
|
|
1142
|
+
password: \${{ secrets.GITHUB_TOKEN }}
|
|
1143
|
+
- uses: docker/build-push-action@v6
|
|
1144
|
+
with:
|
|
1145
|
+
context: .
|
|
1146
|
+
platforms: linux/arm64
|
|
1147
|
+
push: true
|
|
1148
|
+
tags: \${{ steps.img.outputs.ref }}
|
|
1149
|
+
- name: Notify wrapper to deploy
|
|
1150
|
+
env:
|
|
1151
|
+
GH_TOKEN: \${{ secrets.GREENLIGHT_DISPATCH_TOKEN }}
|
|
1152
|
+
if: \${{ env.GH_TOKEN != '' }}
|
|
1153
|
+
run: |
|
|
1154
|
+
gh api repos/${wrapperRepo}/dispatches \\
|
|
1155
|
+
-f event_type=deploy-${name} \\
|
|
1156
|
+
-F client_payload[sha]=\${{ github.sha }}
|
|
1157
|
+
`;
|
|
1158
|
+
}
|
|
1159
|
+
function deployListenerYml(name, toolRepo) {
|
|
1160
|
+
const SECRET = `${name.toUpperCase().replace(/-/g, "_")}_OCI_CONTAINER_INSTANCE_OCID`;
|
|
1161
|
+
return `name: greenlight-deploy-${name}
|
|
1162
|
+
|
|
1163
|
+
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
1164
|
+
on:
|
|
1165
|
+
repository_dispatch:
|
|
1166
|
+
types: [deploy-${name}]
|
|
1167
|
+
workflow_dispatch:
|
|
1168
|
+
|
|
1169
|
+
permissions:
|
|
1170
|
+
contents: read
|
|
1171
|
+
|
|
1172
|
+
jobs:
|
|
1173
|
+
deploy:
|
|
1174
|
+
runs-on: ubuntu-latest
|
|
1175
|
+
steps:
|
|
1176
|
+
- uses: actions/checkout@v4
|
|
1177
|
+
- uses: jdx/mise-action@v2
|
|
1178
|
+
- run: pnpm install --frozen-lockfile
|
|
1179
|
+
- run: pip install --quiet oci-cli
|
|
1180
|
+
- name: Deploy (restart container instance -> re-pull GHCR image)
|
|
1181
|
+
env:
|
|
1182
|
+
OCI_CLI_USER: \${{ secrets.OCI_CLI_USER }}
|
|
1183
|
+
OCI_CLI_TENANCY: \${{ secrets.OCI_CLI_TENANCY }}
|
|
1184
|
+
OCI_CLI_FINGERPRINT: \${{ secrets.OCI_CLI_FINGERPRINT }}
|
|
1185
|
+
OCI_CLI_KEY_CONTENT: \${{ secrets.OCI_CLI_KEY_CONTENT }}
|
|
1186
|
+
OCI_CLI_REGION: \${{ secrets.OCI_CLI_REGION }}
|
|
1187
|
+
OCI_CONTAINER_INSTANCE_OCID: \${{ secrets.${SECRET} }}
|
|
1188
|
+
run: pnpm exec greenlight deploy ${name} --env prod
|
|
1189
|
+
- name: Report status back to ${toolRepo}
|
|
1190
|
+
if: \${{ always() && github.event.client_payload.sha != '' }}
|
|
1191
|
+
env:
|
|
1192
|
+
GH_TOKEN: \${{ secrets.GREENLIGHT_STATUS_TOKEN }}
|
|
1193
|
+
run: |
|
|
1194
|
+
[ -z "$GH_TOKEN" ] && exit 0
|
|
1195
|
+
gh api repos/${toolRepo}/statuses/\${{ github.event.client_payload.sha }} \\
|
|
1196
|
+
-f state="\${{ job.status == 'success' && 'success' || 'failure' }}" \\
|
|
1197
|
+
-f context="greenlight/deploy-${name}" \\
|
|
1198
|
+
-f description="\${{ job.status }}"
|
|
1199
|
+
`;
|
|
1200
|
+
}
|
|
1201
|
+
function writeIfAbsent(path, contents, label) {
|
|
1202
|
+
if (existsSync7(path)) {
|
|
1203
|
+
console.log(`\xB7 ${label} exists \u2014 left as-is`);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
mkdirSync4(resolve7(path, ".."), { recursive: true });
|
|
1207
|
+
writeFileSync4(path, contents);
|
|
1208
|
+
console.log(`\u2714 ${label}`);
|
|
1209
|
+
}
|
|
1210
|
+
async function adoptCommand(args) {
|
|
1211
|
+
const name = args[0];
|
|
1212
|
+
if (!name || name.startsWith("-")) {
|
|
1213
|
+
throw new Error(
|
|
1214
|
+
"usage: greenlight adopt <name> --repo <url|path> --lane <l> --target <t> [--data --auth --envs] [--standalone]\n default: wrap <repo> as a tools/<name> submodule + edit infra in this wrapper + push the loop kit into the tool repo.\n --standalone: scaffold a full self-contained consumer into the tool repo (it owns its whole stack)."
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
const repoArg = flag3(args, "--repo");
|
|
1218
|
+
if (!repoArg) throw new Error("adopt needs --repo <url|path> (the existing tool repo to adopt)");
|
|
1219
|
+
const lane = flag3(args, "--lane");
|
|
1220
|
+
const target = flag3(args, "--target");
|
|
1221
|
+
if (!lane || !target) throw new Error("adopt needs --lane and --target");
|
|
1222
|
+
const data = flag3(args, "--data") ?? "none";
|
|
1223
|
+
const auth = flag3(args, "--auth") ?? "none";
|
|
1224
|
+
const envs = flag3(args, "--envs")?.split(",") ?? ["beta", "prod"];
|
|
1225
|
+
const { path: regPath, config: reg } = await loadManifest();
|
|
1226
|
+
if (regPath.endsWith(".example.ts")) {
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
"run adopt from your site repo (needs a real greenlight.config.ts; run `greenlight init` first)"
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
if (reg.tools.some((t) => t.name === name) || name === "blog") {
|
|
1232
|
+
throw new Error(`"${name}" already in the registry`);
|
|
1233
|
+
}
|
|
1234
|
+
const domain = flag3(args, "--domain") ?? reg.domain;
|
|
1235
|
+
const ctx = { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath };
|
|
1236
|
+
if (args.includes("--standalone")) return adoptStandalone(ctx);
|
|
1237
|
+
return adoptWrapper(ctx);
|
|
1238
|
+
}
|
|
1239
|
+
async function adoptWrapper(ctx) {
|
|
1240
|
+
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1241
|
+
const cwd = process.cwd();
|
|
1242
|
+
const toolRel = `tools/${name}`;
|
|
1243
|
+
const dest = resolve7(cwd, toolRel);
|
|
1244
|
+
console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
|
|
1245
|
+
`);
|
|
1246
|
+
if (!existsSync7(dest)) {
|
|
1247
|
+
try {
|
|
1248
|
+
execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
|
|
1249
|
+
console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
`git submodule add failed (${detail}). Ensure this wrapper is a git repo and <repo> is reachable.`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
console.log(`\xB7 ${toolRel} exists \u2014 skipping submodule add`);
|
|
1258
|
+
}
|
|
1259
|
+
const nextReg = addTool(reg, {
|
|
1260
|
+
name,
|
|
1261
|
+
lane,
|
|
1262
|
+
target,
|
|
1263
|
+
data,
|
|
1264
|
+
auth,
|
|
1265
|
+
envs,
|
|
1266
|
+
dir: toolRel,
|
|
1267
|
+
external: true,
|
|
1268
|
+
adopted: true
|
|
1269
|
+
});
|
|
1270
|
+
writeFileSync4(regPath, serializeConfig(nextReg));
|
|
1271
|
+
console.log(`\u2714 registered "${name}" (external, dir ${toolRel}) in the wrapper manifest`);
|
|
1272
|
+
const slug = parseRepo(repoArg) ?? parseRepo(safeGit(dest, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1273
|
+
writeIfAbsent(
|
|
1274
|
+
join2(cwd, `infra/${name}.tf`),
|
|
1275
|
+
emitToolTf({ name, domain, lane, target, data, envs, slug, external: true }),
|
|
1276
|
+
`infra/${name}.tf`
|
|
1277
|
+
);
|
|
1278
|
+
writeIfAbsent(
|
|
1279
|
+
join2(cwd, `verify/${name}.config.ts`),
|
|
1280
|
+
starterVerifyConfig(lane),
|
|
1281
|
+
`verify/${name}.config.ts`
|
|
1282
|
+
);
|
|
1283
|
+
const providers = providersForTool({ target, data });
|
|
1284
|
+
if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1285
|
+
console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
|
|
1286
|
+
}
|
|
1287
|
+
materializeAgentKit(dest, { target, data });
|
|
1288
|
+
addGreenlightScript(dest);
|
|
1289
|
+
if (target === "oci") {
|
|
1290
|
+
const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
|
|
1291
|
+
writeIfAbsent(
|
|
1292
|
+
join2(cwd, `.github/workflows/greenlight-deploy-${name}.yml`),
|
|
1293
|
+
deployListenerYml(name, slug),
|
|
1294
|
+
`.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
|
|
1295
|
+
);
|
|
1296
|
+
writeIfAbsent(
|
|
1297
|
+
join2(dest, ".github/workflows/greenlight-build.yml"),
|
|
1298
|
+
containerBuildYml(name, wrapperSlug),
|
|
1299
|
+
`${toolRel}/.github/workflows/greenlight-build.yml (provider-agnostic build \u2192 GHCR \u2192 dispatch)`
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
console.log(`
|
|
1303
|
+
Next:
|
|
1304
|
+
(in the tool repo) commit the Greenlight kit + build workflow so they travel with the submodule:
|
|
1305
|
+
cd ${toolRel} && git add .claude .mcp.json CLAUDE.md .github && git commit -m "chore: greenlight loop kit + build"
|
|
1306
|
+
(in the wrapper) review infra/${name}.tf, then commit the submodule + infra + listener:
|
|
1307
|
+
git add .gitmodules ${toolRel} infra/${name}.tf verify/${name}.config.ts greenlight.config.ts .github
|
|
1308
|
+
git commit && git push # CI (infra.yml) applies. Tool's CI builds; wrapper deploys.${target === "oci" ? `
|
|
1309
|
+
Secrets: in ${slug} set GREENLIGHT_DISPATCH_TOKEN; in this wrapper set the OCI_CLI_* creds +
|
|
1310
|
+
${name.toUpperCase().replace(/-/g, "_")}_OCI_CONTAINER_INSTANCE_OCID (from the TF output) + GREENLIGHT_STATUS_TOKEN.` : ""}`);
|
|
1311
|
+
}
|
|
1312
|
+
async function adoptStandalone(ctx) {
|
|
1313
|
+
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
1314
|
+
const repo = resolve7(process.cwd(), repoArg);
|
|
1315
|
+
if (!existsSync7(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
|
|
1316
|
+
const regVendor = resolve7(process.cwd(), "vendor");
|
|
1317
|
+
const vendor = vendorDeps(regVendor);
|
|
1318
|
+
if (Object.keys(vendor).length === 0) {
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
"no vendor/*.tgz in this repo \u2014 --standalone bootstraps the tool from the registry repo's vendored tarballs (or publish to npm first)"
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
console.log(`adopting "${name}" (${lane}/${target}) into ${repo} (standalone)
|
|
1324
|
+
`);
|
|
1325
|
+
const toolEntry = { name, lane, target, data, auth, envs, dir: ".", adopted: true };
|
|
1326
|
+
const toolConfig = addTool({ domain, alerts: { sink: "github-issue" }, tools: [] }, toolEntry);
|
|
1327
|
+
writeIfAbsent(
|
|
1328
|
+
join2(repo, "greenlight.config.ts"),
|
|
1329
|
+
serializeConfig(toolConfig),
|
|
1330
|
+
"greenlight.config.ts"
|
|
1331
|
+
);
|
|
1332
|
+
const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
|
|
1333
|
+
const pkgPath = join2(repo, "package.json");
|
|
1334
|
+
const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
|
|
1335
|
+
writeFileSync4(
|
|
1336
|
+
pkgPath,
|
|
1337
|
+
`${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
|
|
1338
|
+
`
|
|
1339
|
+
);
|
|
1340
|
+
console.log("\u2714 package.json (merged framework deps + overrides)");
|
|
1341
|
+
const repoVendor = join2(repo, "vendor");
|
|
1342
|
+
mkdirSync4(repoVendor, { recursive: true });
|
|
1343
|
+
for (const f of readdirSync(regVendor)) {
|
|
1344
|
+
if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
|
|
1345
|
+
}
|
|
1346
|
+
console.log(`\u2714 vendor/ (${Object.keys(vendor).length} tarballs)`);
|
|
1347
|
+
writeIfAbsent(
|
|
1348
|
+
join2(repo, "infra/main.tf"),
|
|
1349
|
+
infraTf(name, domain, lane, target, data, envs, slug),
|
|
1350
|
+
"infra/main.tf"
|
|
1351
|
+
);
|
|
1352
|
+
writeIfAbsent(
|
|
1353
|
+
join2(repo, ".github/workflows/greenlight-deploy.yml"),
|
|
1354
|
+
deployYml(name),
|
|
1355
|
+
".github/workflows/greenlight-deploy.yml"
|
|
1356
|
+
);
|
|
1357
|
+
writeIfAbsent(
|
|
1358
|
+
join2(repo, ".github/workflows/greenlight-promote.yml"),
|
|
1359
|
+
promoteYml(name),
|
|
1360
|
+
".github/workflows/greenlight-promote.yml"
|
|
1361
|
+
);
|
|
1362
|
+
writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
|
|
1363
|
+
materializeAgentKit(repo, { target, data });
|
|
1364
|
+
writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
|
|
1365
|
+
writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
|
|
1366
|
+
const nextReg = addTool(reg, {
|
|
1367
|
+
name,
|
|
1368
|
+
lane,
|
|
1369
|
+
target,
|
|
1370
|
+
data,
|
|
1371
|
+
auth,
|
|
1372
|
+
envs,
|
|
1373
|
+
external: true,
|
|
1374
|
+
adopted: true
|
|
1375
|
+
});
|
|
1376
|
+
writeFileSync4(regPath, serializeConfig(nextReg));
|
|
1377
|
+
console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
|
|
1378
|
+
console.log(`
|
|
1379
|
+
Next (in the adopted repo):
|
|
1380
|
+
cd ${repoArg}
|
|
1381
|
+
pnpm install
|
|
1382
|
+
echo -n "$CLOUDFLARE_API_TOKEN" | gh secret set CLOUDFLARE_API_TOKEN
|
|
1383
|
+
git checkout -b develop && git push -u origin develop
|
|
1384
|
+
greenlight preview ${name} # local; or deploy --env beta once creds are set
|
|
1385
|
+
Note: deploying ${target} needs the ${target} adapter (workers is built; oci/vercel are follow-ups).`);
|
|
1386
|
+
}
|
|
1387
|
+
function addGreenlightScript(dir) {
|
|
1388
|
+
const pkgPath = join2(dir, "package.json");
|
|
1389
|
+
if (!existsSync7(pkgPath)) {
|
|
1390
|
+
console.log(
|
|
1391
|
+
"\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
|
|
1392
|
+
);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
1396
|
+
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
1397
|
+
if (pkg.scripts.greenlight) {
|
|
1398
|
+
console.log("\xB7 package.json already has a greenlight script");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
|
|
1402
|
+
writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
1403
|
+
`);
|
|
1404
|
+
console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
|
|
1405
|
+
}
|
|
1406
|
+
function safeGit(cwd, gitArgs) {
|
|
1407
|
+
try {
|
|
1408
|
+
return execFileSync2("git", gitArgs, {
|
|
1409
|
+
cwd,
|
|
1410
|
+
encoding: "utf8",
|
|
1411
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1412
|
+
});
|
|
1413
|
+
} catch {
|
|
1414
|
+
return "";
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// src/commands/config.ts
|
|
1419
|
+
import { relative } from "path";
|
|
1420
|
+
async function configCommand() {
|
|
1421
|
+
const { path, config } = await loadManifest();
|
|
1422
|
+
console.log(`\u2714 Loaded & validated ${relative(process.cwd(), path)}
|
|
1423
|
+
`);
|
|
1424
|
+
console.log(JSON.stringify(config, null, 2));
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ../packages/adapters/src/index.ts
|
|
1428
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
1429
|
+
import { join as join3 } from "path";
|
|
1430
|
+
function run(cmd, args, cwd, extraEnv) {
|
|
1431
|
+
execFileSync3(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...extraEnv } });
|
|
1432
|
+
}
|
|
1433
|
+
function workersAdapter(ctx) {
|
|
1434
|
+
const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
|
|
1435
|
+
return {
|
|
1436
|
+
target: "workers",
|
|
1437
|
+
async build(toolDir, env) {
|
|
1438
|
+
let siteEnv;
|
|
1439
|
+
try {
|
|
1440
|
+
siteEnv = { SITE_URL: url(env) };
|
|
1441
|
+
} catch {
|
|
1442
|
+
siteEnv = void 0;
|
|
1443
|
+
}
|
|
1444
|
+
run("pnpm", ["run", "build"], toolDir, siteEnv);
|
|
1445
|
+
return { artifactDir: join3(toolDir, "dist") };
|
|
1446
|
+
},
|
|
1447
|
+
async deploy(toolDir, env) {
|
|
1448
|
+
run("pnpm", ["exec", "wrangler", "deploy", "--env", env], toolDir);
|
|
1449
|
+
return { url: url(env) };
|
|
1450
|
+
},
|
|
1451
|
+
url,
|
|
1452
|
+
async teardown() {
|
|
1453
|
+
throw new Error("workers teardown is not wired yet (later phase).");
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
function ociConfig(source = process.env) {
|
|
1458
|
+
return { containerInstanceId: source.OCI_CONTAINER_INSTANCE_OCID };
|
|
1459
|
+
}
|
|
1460
|
+
function ociRestartArgs(containerInstanceId) {
|
|
1461
|
+
return [
|
|
1462
|
+
"container-instances",
|
|
1463
|
+
"container-instance",
|
|
1464
|
+
"restart",
|
|
1465
|
+
"--container-instance-id",
|
|
1466
|
+
containerInstanceId
|
|
1467
|
+
];
|
|
1468
|
+
}
|
|
1469
|
+
function ociAdapter(ctx) {
|
|
1470
|
+
const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
|
|
1471
|
+
return {
|
|
1472
|
+
target: "oci",
|
|
1473
|
+
async build() {
|
|
1474
|
+
return { artifactDir: "." };
|
|
1475
|
+
},
|
|
1476
|
+
async deploy(_toolDir, env) {
|
|
1477
|
+
const { containerInstanceId } = ociConfig();
|
|
1478
|
+
if (!containerInstanceId) {
|
|
1479
|
+
throw new Error("oci deploy needs OCI_CONTAINER_INSTANCE_OCID (the Terraform output)");
|
|
1480
|
+
}
|
|
1481
|
+
run("oci", ociRestartArgs(containerInstanceId), ".");
|
|
1482
|
+
return { url: url(env) };
|
|
1483
|
+
},
|
|
1484
|
+
url,
|
|
1485
|
+
async teardown() {
|
|
1486
|
+
throw new Error("oci teardown is Terraform \u2014 `terraform destroy` the oci-instance module.");
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
function vercelSkeletonAdapter(ctx) {
|
|
1491
|
+
const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
|
|
1492
|
+
const notWired = () => {
|
|
1493
|
+
throw new Error(
|
|
1494
|
+
"vercel deploy rides Vercel git-integration \u2014 Greenlight manages its infra, not a push-deploy."
|
|
1495
|
+
);
|
|
1496
|
+
};
|
|
1497
|
+
return {
|
|
1498
|
+
target: "vercel",
|
|
1499
|
+
build: async () => notWired(),
|
|
1500
|
+
deploy: async () => notWired(),
|
|
1501
|
+
url,
|
|
1502
|
+
teardown: async () => notWired()
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function createAdapter(target, ctx) {
|
|
1506
|
+
switch (target) {
|
|
1507
|
+
case "workers":
|
|
1508
|
+
return workersAdapter(ctx);
|
|
1509
|
+
case "oci":
|
|
1510
|
+
return ociAdapter(ctx);
|
|
1511
|
+
case "vercel":
|
|
1512
|
+
return vercelSkeletonAdapter(ctx);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// src/commands/deploy.ts
|
|
1517
|
+
function flag4(args, name) {
|
|
1518
|
+
const i = args.indexOf(name);
|
|
1519
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
1520
|
+
}
|
|
1521
|
+
async function deployCommand(args) {
|
|
1522
|
+
const name = args[0];
|
|
1523
|
+
if (!name || name.startsWith("-")) {
|
|
1524
|
+
throw new Error("usage: greenlight deploy <name> --env <preview|beta|prod>");
|
|
1525
|
+
}
|
|
1526
|
+
const env = flag4(args, "--env");
|
|
1527
|
+
if (env !== "preview" && env !== "beta" && env !== "prod") {
|
|
1528
|
+
throw new Error("deploy needs --env preview|beta|prod");
|
|
1529
|
+
}
|
|
1530
|
+
const { config } = await loadManifest();
|
|
1531
|
+
const entry = resolveEntry(config, name);
|
|
1532
|
+
if (entry.external) {
|
|
1533
|
+
throw new Error(`"${name}" is external (registry pointer) \u2014 deploy it from its own repo`);
|
|
1534
|
+
}
|
|
1535
|
+
const adapter = createAdapter(entry.target, { domain: config.domain, name: entry.name });
|
|
1536
|
+
console.log(`build ${name} (${entry.lane}/${entry.target}) in ${entry.dir}`);
|
|
1537
|
+
await adapter.build(entry.dir, env);
|
|
1538
|
+
console.log(`deploy ${name} \u2192 ${env}`);
|
|
1539
|
+
const { url } = await adapter.deploy(entry.dir, env);
|
|
1540
|
+
console.log(`\u2714 deployed: ${url}`);
|
|
1541
|
+
if (entry.lane === "mcp") console.log(` connect: ${url}/mcp`);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/commands/doctor.ts
|
|
1545
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1546
|
+
import { join as join4 } from "path";
|
|
1547
|
+
function dirCheck(label, dir) {
|
|
1548
|
+
return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
|
|
1549
|
+
}
|
|
1550
|
+
function runDoctor(config, root) {
|
|
1551
|
+
const checks = [];
|
|
1552
|
+
if (config.blog) checks.push(dirCheck("blog", join4(root, "apps/blog")));
|
|
1553
|
+
for (const t of config.tools) {
|
|
1554
|
+
if (t.external) {
|
|
1555
|
+
const url = resolveUrl({
|
|
1556
|
+
domain: config.domain,
|
|
1557
|
+
name: t.name,
|
|
1558
|
+
env: "prod",
|
|
1559
|
+
mcp: t.lane === "mcp"
|
|
1560
|
+
});
|
|
1561
|
+
checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
const dir = join4(root, t.dir ?? join4("tools", t.name));
|
|
1565
|
+
checks.push(dirCheck(t.name, dir));
|
|
1566
|
+
if (t.lane === "mcp") {
|
|
1567
|
+
const vc = join4(dir, "verify.config.ts");
|
|
1568
|
+
checks.push({
|
|
1569
|
+
name: `${t.name}: verify.config.ts`,
|
|
1570
|
+
status: existsSync8(vc) ? "ok" : "warn",
|
|
1571
|
+
detail: existsSync8(vc) ? void 0 : "missing \u2014 verify will use the lane default"
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const needsKeepalive = config.tools.filter((t) => t.data === "supabase" || t.target === "oci");
|
|
1576
|
+
checks.push({
|
|
1577
|
+
name: "keepalive coverage",
|
|
1578
|
+
status: needsKeepalive.length > 0 ? "ok" : "skip",
|
|
1579
|
+
detail: needsKeepalive.length > 0 ? needsKeepalive.map((t) => `${t.name} (${t.data === "supabase" ? "supabase" : "oci"})`).join(", ") : "no data:supabase / target:oci tools"
|
|
1580
|
+
});
|
|
1581
|
+
for (const name of [
|
|
1582
|
+
"DNS propagation",
|
|
1583
|
+
"terraform drift",
|
|
1584
|
+
"Vercel cap headroom",
|
|
1585
|
+
"keepalive health (live)",
|
|
1586
|
+
"OCI PAYG status",
|
|
1587
|
+
"framework version drift"
|
|
1588
|
+
]) {
|
|
1589
|
+
checks.push({ name, status: "skip", detail: "needs provider creds / packages (Phase 5/7/8)" });
|
|
1590
|
+
}
|
|
1591
|
+
return checks;
|
|
1592
|
+
}
|
|
1593
|
+
var ICON = { ok: "\u2714", warn: "!", fail: "\u2718", skip: "\xB7" };
|
|
1594
|
+
async function doctorCommand() {
|
|
1595
|
+
let config;
|
|
1596
|
+
try {
|
|
1597
|
+
({ config } = await loadManifest());
|
|
1598
|
+
console.log("\u2714 manifest: loaded & valid\n");
|
|
1599
|
+
} catch (e) {
|
|
1600
|
+
console.error(`\u2718 manifest: ${e instanceof Error ? e.message : String(e)}`);
|
|
1601
|
+
process.exit(1);
|
|
1602
|
+
}
|
|
1603
|
+
const checks = runDoctor(config, process.cwd());
|
|
1604
|
+
for (const c of checks) {
|
|
1605
|
+
console.log(` ${ICON[c.status]} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
|
|
1606
|
+
}
|
|
1607
|
+
const failed = checks.filter((c) => c.status === "fail").length;
|
|
1608
|
+
console.log(`
|
|
1609
|
+
${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
1610
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// src/commands/init.ts
|
|
1614
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1615
|
+
import { resolve as resolve8 } from "path";
|
|
1616
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
1617
|
+
function flag5(args, name) {
|
|
1618
|
+
const i = args.indexOf(name);
|
|
1619
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
1620
|
+
}
|
|
1621
|
+
var TOKEN_FLAGS = {
|
|
1622
|
+
"--cf-token": "CLOUDFLARE_API_TOKEN",
|
|
1623
|
+
"--github-token": "GITHUB_TOKEN",
|
|
1624
|
+
"--vercel-token": "VERCEL_TOKEN",
|
|
1625
|
+
"--supabase-url": "SUPABASE_URL",
|
|
1626
|
+
"--supabase-key": "SUPABASE_SERVICE_ROLE_KEY"
|
|
1627
|
+
};
|
|
1628
|
+
async function initCommand(args) {
|
|
1629
|
+
const force = args.includes("--force");
|
|
1630
|
+
let domain = flag5(args, "--domain");
|
|
1631
|
+
if (!domain) {
|
|
1632
|
+
if (!process.stdin.isTTY) throw new Error("init needs --domain <domain> (no TTY for prompts)");
|
|
1633
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1634
|
+
domain = (await rl.question("Domain (e.g. example.dev): ")).trim();
|
|
1635
|
+
rl.close();
|
|
1636
|
+
}
|
|
1637
|
+
if (!domain) throw new Error("a domain is required");
|
|
1638
|
+
const cwd = process.cwd();
|
|
1639
|
+
const configPath = resolve8(cwd, "greenlight.config.ts");
|
|
1640
|
+
if (existsSync9(configPath) && !force) {
|
|
1641
|
+
throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
|
|
1642
|
+
}
|
|
1643
|
+
writeFileSync5(configPath, scaffoldConfig(domain));
|
|
1644
|
+
console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
|
|
1645
|
+
const secrets = [];
|
|
1646
|
+
for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
|
|
1647
|
+
const v = flag5(args, f);
|
|
1648
|
+
if (v) secrets.push(`${key}=${v}`);
|
|
1649
|
+
}
|
|
1650
|
+
if (secrets.length > 0) {
|
|
1651
|
+
mkdirSync5(resolve8(cwd, ".greenlight"), { recursive: true });
|
|
1652
|
+
writeFileSync5(resolve8(cwd, ".greenlight/secrets.env"), `${secrets.join("\n")}
|
|
1653
|
+
`, {
|
|
1654
|
+
mode: 384
|
|
1655
|
+
});
|
|
1656
|
+
console.log(`\u2714 wrote .greenlight/secrets.env (${secrets.length} token(s), gitignored)`);
|
|
1657
|
+
}
|
|
1658
|
+
if (process.stdin.isTTY && !args.includes("--no-tokens")) {
|
|
1659
|
+
try {
|
|
1660
|
+
await ensureTokensForTool(cwd, {}, { verify: !args.includes("--no-verify") });
|
|
1661
|
+
} catch (e) {
|
|
1662
|
+
console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
let pushed = false;
|
|
1666
|
+
if (existsSync9(resolve8(cwd, ".greenlight/secrets.env")) && !args.includes("--no-push")) {
|
|
1667
|
+
try {
|
|
1668
|
+
const { repo, count } = syncSecrets({ cwd, repo: flag5(args, "--repo") });
|
|
1669
|
+
console.log(`\u2714 pushed ${count} secret(s) to ${repo} (GitHub Actions)`);
|
|
1670
|
+
pushed = true;
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
console.log(`! skipped pushing secrets: ${e instanceof Error ? e.message : String(e)}`);
|
|
1673
|
+
console.log(" run `greenlight secrets sync` once `gh` is authenticated.");
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
console.log(`
|
|
1677
|
+
Next:
|
|
1678
|
+
greenlight add <name> --lane mcp --target oci # scaffold a tool${pushed ? "" : "\n greenlight secrets sync # push tokens to GitHub Actions"}
|
|
1679
|
+
greenlight doctor # check consistency
|
|
1680
|
+
terraform -chdir=infra apply # branches/protection/DNS (module "repo"/"tool")
|
|
1681
|
+
greenlight deploy blog --env prod # first live deploy`);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// src/commands/preview.ts
|
|
1685
|
+
import { execFileSync as execFileSync4, spawn } from "child_process";
|
|
1686
|
+
import { resolve as resolve10 } from "path";
|
|
1687
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
1688
|
+
|
|
1689
|
+
// src/commands/verify.ts
|
|
1690
|
+
import { resolve as resolve9 } from "path";
|
|
1691
|
+
function defaultSpec(lane) {
|
|
1692
|
+
switch (lane) {
|
|
1693
|
+
case "astro":
|
|
1694
|
+
return { mode: "api", checks: [{ path: "/", status: 200 }], noBrokenInternalLinks: true };
|
|
1695
|
+
case "next":
|
|
1696
|
+
return { mode: "api", checks: [{ path: "/", status: 200 }] };
|
|
1697
|
+
case "mcp":
|
|
1698
|
+
return { mode: "mcp", expectTools: [] };
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function printReport(report) {
|
|
1702
|
+
console.log(`verify ${report.mode} ${report.url}
|
|
1703
|
+
`);
|
|
1704
|
+
for (const c of report.checks) {
|
|
1705
|
+
console.log(` ${c.pass ? "\u2714" : "\u2718"} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
|
|
1706
|
+
}
|
|
1707
|
+
console.log(`
|
|
1708
|
+
${report.pass ? "\u2714 PASS" : "\u2718 FAIL"}`);
|
|
1709
|
+
}
|
|
1710
|
+
function flag6(args, name) {
|
|
1711
|
+
const i = args.indexOf(name);
|
|
1712
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
1713
|
+
}
|
|
1714
|
+
async function verifyCommand(args) {
|
|
1715
|
+
const name = args[0];
|
|
1716
|
+
if (!name || name.startsWith("-")) {
|
|
1717
|
+
throw new Error("usage: greenlight verify <name> [--env <beta|prod> | --url <url>]");
|
|
1718
|
+
}
|
|
1719
|
+
const { config } = await loadManifest();
|
|
1720
|
+
const entry = resolveEntry(config, name);
|
|
1721
|
+
const override = flag6(args, "--url");
|
|
1722
|
+
let url;
|
|
1723
|
+
if (override) {
|
|
1724
|
+
url = entry.lane === "mcp" && !override.endsWith("/mcp") ? `${override}/mcp` : override;
|
|
1725
|
+
} else {
|
|
1726
|
+
const env = flag6(args, "--env");
|
|
1727
|
+
if (env !== "beta" && env !== "prod") {
|
|
1728
|
+
throw new Error(
|
|
1729
|
+
"verify needs --env beta|prod (or --url <url>). preview URLs come from the adapter deploy."
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
url = resolveUrl({ domain: config.domain, name: entry.name, env, mcp: entry.lane === "mcp" });
|
|
1733
|
+
}
|
|
1734
|
+
const loaded = (entry.external ? await loadExternalVerifySpec(name) : await loadVerifySpec(entry.dir)) ?? defaultSpec(entry.lane);
|
|
1735
|
+
const specs = Array.isArray(loaded) ? loaded : [loaded];
|
|
1736
|
+
const waitFlag = flag6(args, "--wait");
|
|
1737
|
+
const reachableTimeoutMs = (waitFlag !== void 0 ? Number(waitFlag) : override ? 0 : 90) * 1e3;
|
|
1738
|
+
if (reachableTimeoutMs > 0) {
|
|
1739
|
+
console.log(`waiting up to ${reachableTimeoutMs / 1e3}s for ${url} to become reachable\u2026`);
|
|
1740
|
+
}
|
|
1741
|
+
const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
|
|
1742
|
+
const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
|
|
1743
|
+
for (const report of reports) printReport(report);
|
|
1744
|
+
const pass = allPass(reports);
|
|
1745
|
+
if (reports.length > 1)
|
|
1746
|
+
console.log(`
|
|
1747
|
+
${pass ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports.length} specs)`);
|
|
1748
|
+
process.exit(pass ? 0 : 1);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// src/commands/preview.ts
|
|
1752
|
+
function servePlan(lane, port) {
|
|
1753
|
+
switch (lane) {
|
|
1754
|
+
case "mcp":
|
|
1755
|
+
return { build: false, script: "start", port: port ?? 8787, path: "/mcp" };
|
|
1756
|
+
default:
|
|
1757
|
+
return { build: true, script: "preview", port: port ?? 4321, path: "" };
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
function flag7(args, name) {
|
|
1761
|
+
const i = args.indexOf(name);
|
|
1762
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
1763
|
+
}
|
|
1764
|
+
async function waitForServer(url, timeoutMs = 3e4) {
|
|
1765
|
+
const deadline = Date.now() + timeoutMs;
|
|
1766
|
+
while (Date.now() < deadline) {
|
|
1767
|
+
try {
|
|
1768
|
+
await fetch(url, { signal: AbortSignal.timeout(2e3) });
|
|
1769
|
+
return true;
|
|
1770
|
+
} catch {
|
|
1771
|
+
await sleep(400);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return false;
|
|
1775
|
+
}
|
|
1776
|
+
async function previewCommand(args) {
|
|
1777
|
+
const name = args[0];
|
|
1778
|
+
if (!name || name.startsWith("-")) {
|
|
1779
|
+
throw new Error("usage: greenlight preview <name> [--port <n>]");
|
|
1780
|
+
}
|
|
1781
|
+
const portArg = flag7(args, "--port");
|
|
1782
|
+
const { config } = await loadManifest();
|
|
1783
|
+
const entry = resolveEntry(config, name);
|
|
1784
|
+
if (entry.external) {
|
|
1785
|
+
throw new Error(`"${name}" is external (registry pointer) \u2014 preview it from its own repo`);
|
|
1786
|
+
}
|
|
1787
|
+
const plan = servePlan(entry.lane, portArg ? Number(portArg) : void 0);
|
|
1788
|
+
if (plan.build) {
|
|
1789
|
+
console.log(`build ${name} (${entry.dir})`);
|
|
1790
|
+
execFileSync4("pnpm", ["-C", entry.dir, "run", "build"], { stdio: "inherit" });
|
|
1791
|
+
}
|
|
1792
|
+
console.log(`serve ${name} on :${plan.port}`);
|
|
1793
|
+
const runArgs = ["-C", entry.dir, "run", plan.script];
|
|
1794
|
+
if (plan.script === "preview") runArgs.push("--", "--port", String(plan.port));
|
|
1795
|
+
const child = spawn("pnpm", runArgs, {
|
|
1796
|
+
env: { ...process.env, PORT: String(plan.port) },
|
|
1797
|
+
stdio: "ignore",
|
|
1798
|
+
detached: true
|
|
1799
|
+
});
|
|
1800
|
+
let pass = false;
|
|
1801
|
+
try {
|
|
1802
|
+
const base = `http://localhost:${plan.port}`;
|
|
1803
|
+
if (!await waitForServer(base)) {
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
`server did not start on :${plan.port} (check the tool's ${plan.script} script)`
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
const loaded = await loadVerifySpec(entry.dir) ?? defaultSpec(entry.lane);
|
|
1809
|
+
const specs = Array.isArray(loaded) ? loaded : [loaded];
|
|
1810
|
+
const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
|
|
1811
|
+
const reports = await verifyAll(base + plan.path, specs, { toolDir });
|
|
1812
|
+
for (const report of reports) printReport(report);
|
|
1813
|
+
pass = allPass(reports);
|
|
1814
|
+
} finally {
|
|
1815
|
+
if (child.pid) {
|
|
1816
|
+
try {
|
|
1817
|
+
process.kill(-child.pid, "SIGTERM");
|
|
1818
|
+
} catch {
|
|
1819
|
+
child.kill("SIGTERM");
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
process.exit(pass ? 0 : 1);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// ../packages/loop/src/promote.ts
|
|
1827
|
+
import { execFileSync as execFileSync5 } from "child_process";
|
|
1828
|
+
function git(repoDir, args) {
|
|
1829
|
+
execFileSync5("git", args, { cwd: repoDir, stdio: "ignore" });
|
|
1830
|
+
}
|
|
1831
|
+
function gitOut(repoDir, args) {
|
|
1832
|
+
return execFileSync5("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
|
|
1833
|
+
}
|
|
1834
|
+
function canPromote(repoDir, from = "develop", to = "main") {
|
|
1835
|
+
const run2 = (args) => git(repoDir, args);
|
|
1836
|
+
try {
|
|
1837
|
+
run2(["rev-parse", "--verify", "--quiet", from]);
|
|
1838
|
+
run2(["rev-parse", "--verify", "--quiet", to]);
|
|
1839
|
+
} catch {
|
|
1840
|
+
return { canPromote: false, reason: `branch "${from}" or "${to}" not found in ${repoDir}` };
|
|
1841
|
+
}
|
|
1842
|
+
try {
|
|
1843
|
+
run2(["merge-base", "--is-ancestor", to, from]);
|
|
1844
|
+
return { canPromote: true, reason: `"${to}" can fast-forward to "${from}"` };
|
|
1845
|
+
} catch {
|
|
1846
|
+
return {
|
|
1847
|
+
canPromote: false,
|
|
1848
|
+
reason: `"${to}" has diverged from "${from}" \u2014 fast-forward refused. Reconcile first (rebase "${from}" onto "${to}", or merge "${to}" into "${from}") before promoting.`
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
function promote(repoDir, opts = {}) {
|
|
1853
|
+
const from = opts.from ?? "develop";
|
|
1854
|
+
const to = opts.to ?? "main";
|
|
1855
|
+
const check = canPromote(repoDir, from, to);
|
|
1856
|
+
if (!check.canPromote) return { promoted: false, from, to, reason: check.reason };
|
|
1857
|
+
const original = gitOut(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1858
|
+
try {
|
|
1859
|
+
git(repoDir, ["checkout", to]);
|
|
1860
|
+
git(repoDir, ["merge", "--ff-only", from]);
|
|
1861
|
+
if (opts.push) git(repoDir, ["push", "origin", to]);
|
|
1862
|
+
} finally {
|
|
1863
|
+
if (original && original !== "HEAD" && original !== to) {
|
|
1864
|
+
try {
|
|
1865
|
+
git(repoDir, ["checkout", original]);
|
|
1866
|
+
} catch {
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return {
|
|
1871
|
+
promoted: true,
|
|
1872
|
+
from,
|
|
1873
|
+
to,
|
|
1874
|
+
reason: `"${to}" fast-forwarded to "${from}"${opts.push ? " and pushed" : ""}`
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/commands/promote.ts
|
|
1879
|
+
async function promoteCommand(args) {
|
|
1880
|
+
const push = args.includes("--push");
|
|
1881
|
+
const perform = push || args.includes("--perform");
|
|
1882
|
+
const cwd = process.cwd();
|
|
1883
|
+
if (!perform) {
|
|
1884
|
+
const check = canPromote(cwd);
|
|
1885
|
+
console.log(`${check.canPromote ? "\u2714" : "\u2718"} ${check.reason}`);
|
|
1886
|
+
if (check.canPromote) console.log("\nEligible. Re-run with --perform (and --push) to promote.");
|
|
1887
|
+
process.exit(check.canPromote ? 0 : 1);
|
|
1888
|
+
}
|
|
1889
|
+
const result = promote(cwd, { push });
|
|
1890
|
+
console.log(`${result.promoted ? "\u2714" : "\u2718"} ${result.reason}`);
|
|
1891
|
+
process.exit(result.promoted ? 0 : 1);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/bin.ts
|
|
1895
|
+
var HELP = `greenlight <command>
|
|
1896
|
+
|
|
1897
|
+
init --domain <d> [--cf-token ..] [--force] scaffold manifest + secrets, push to GitHub Actions
|
|
1898
|
+
add <name> --lane <l> --target <t> [..] scaffold a tool from a lane template + manifest entry
|
|
1899
|
+
config load & validate the manifest, then print it
|
|
1900
|
+
deploy <name> --env <env> build + deploy an entry via its target adapter
|
|
1901
|
+
preview <name> [--port <n>] build + serve locally + verify (one command)
|
|
1902
|
+
verify <name> [--env <env> | --url <url>] run the verify harness against the URL
|
|
1903
|
+
promote <name> [--perform] [--push] gated develop -> main fast-forward
|
|
1904
|
+
secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
|
|
1905
|
+
agent sync write the loop skill + CLAUDE.md block into this repo
|
|
1906
|
+
adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
|
|
1907
|
+
doctor manifest + repo consistency checks
|
|
1908
|
+
help show this message
|
|
1909
|
+
|
|
1910
|
+
Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see greenlight-v1.md \xA716.`;
|
|
1911
|
+
async function main() {
|
|
1912
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
1913
|
+
switch (cmd) {
|
|
1914
|
+
case void 0:
|
|
1915
|
+
case "help":
|
|
1916
|
+
case "--help":
|
|
1917
|
+
case "-h":
|
|
1918
|
+
console.log(HELP);
|
|
1919
|
+
return;
|
|
1920
|
+
case "init":
|
|
1921
|
+
return initCommand(args);
|
|
1922
|
+
case "add":
|
|
1923
|
+
return addCommand(args);
|
|
1924
|
+
case "config":
|
|
1925
|
+
return configCommand();
|
|
1926
|
+
case "deploy":
|
|
1927
|
+
return deployCommand(args);
|
|
1928
|
+
case "preview":
|
|
1929
|
+
return previewCommand(args);
|
|
1930
|
+
case "verify":
|
|
1931
|
+
return verifyCommand(args);
|
|
1932
|
+
case "promote":
|
|
1933
|
+
return promoteCommand(args);
|
|
1934
|
+
case "secrets":
|
|
1935
|
+
return secretsCommand(args);
|
|
1936
|
+
case "agent":
|
|
1937
|
+
return agentCommand(args);
|
|
1938
|
+
case "adopt":
|
|
1939
|
+
return adoptCommand(args);
|
|
1940
|
+
case "doctor":
|
|
1941
|
+
return doctorCommand();
|
|
1942
|
+
default:
|
|
1943
|
+
throw new Error(`Unknown command "${cmd}".
|
|
1944
|
+
|
|
1945
|
+
${HELP}`);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
main().catch((err) => {
|
|
1949
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1950
|
+
process.exit(1);
|
|
1951
|
+
});
|