@rtrentjones/greenlight 0.6.0 → 0.7.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.
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: provider-docker
3
+ description: Docker host in a Greenlight setup — the `target: docker` runtime for stateful tools on a host you own (VPS/homelab) over SSH; a stable alternative to OCI's idle-reclaimed free tier. Use when wiring or debugging a docker-target tool.
4
+ ---
5
+
6
+ # provider-docker
7
+
8
+ `target: docker` runs a **stateful** tool on **a host you own** — a cheap VPS or a homelab box —
9
+ instead of OCI's Always-Free tier. Same shape as `oci`: **the tool is provider-agnostic and just
10
+ builds a container via GitHub→GHCR; Greenlight owns the tunnel + DNS** and deploys over SSH. Use it
11
+ when you want the OCI loop without OCI's idle-reclaim — you trade "free" for "a host that stays up".
12
+
13
+ ## The model — GHCR image + SSH deploy + Cloudflare tunnel
14
+
15
+ - **Build:** the tool repo's own CI builds + pushes the image to **GHCR** (free), exactly like oci
16
+ (`greenlight-build.yml`), then fires `repository_dispatch(deploy-<name>)` at the wrapper.
17
+ - **Host:** a machine you own runs a **docker-compose** with the GHCR image **+ a cloudflared
18
+ service** using the tunnel token. You set this up **once** (the compute is yours — Greenlight does
19
+ NOT provision it).
20
+ - **Deploy:** the wrapper's deploy listener SSHes the host and runs
21
+ `docker compose pull && docker compose up -d` (via `greenlight deploy <name>`), then verifies prod.
22
+ - **Ingress:** the **same `tunnel` module as oci** — cloudflared dials out from the host, so no
23
+ inbound ports/firewall holes. DNS `<name>.<domain>` → the tunnel (the `tool` module).
24
+
25
+ There is **no compute Terraform** (unlike oci's `oci-network` / `oci-container-instance`) — only the
26
+ `tunnel` + `tool` (DNS) modules. The wrapper emits the `<name>_tunnel_token` output; put it in the
27
+ host's compose env once.
28
+
29
+ ## Tokens — SSH connection (per-tool)
30
+
31
+ The deploy creds are SSH facts, gathered onto the **wrapper** (they live only there):
32
+ - `DOCKER_SSH_HOST` — hostname/IP of the host (required).
33
+ - `DOCKER_SSH_KEY` — the deploy user's **private key** (PEM content; required).
34
+ - `DOCKER_SSH_USER` — SSH user (optional, default `root`).
35
+ - `DOCKER_SSH_PORT` — SSH port (optional, default `22`).
36
+
37
+ All are **per-tool** (`DOCKER_SSH_HOST_<TOOL>`, …) so multiple docker tools can live on different
38
+ hosts without colliding; the deploy workflow maps them to the unsuffixed env the adapter reads. No
39
+ cheap verify (SSH reachability isn't a bearer fetch). Plus the option-B event-driven deploy PATs
40
+ (same as oci): `GREENLIGHT_DISPATCH_TOKEN` on the tool repo, `GREENLIGHT_STATUS_TOKEN` (per-tool) on
41
+ the wrapper. `Cloudflare Tunnel:Edit` is needed on `CLOUDFLARE_API_TOKEN` (as with oci).
42
+
43
+ Gather: `greenlight secrets gather <tool> --repo <wrapper>` (DOCKER_SSH_* + status) and
44
+ `--repo <tool>` (dispatch). Full reference:
45
+ [tokens-reference.md](https://github.com/RTrentJones/greenlight/blob/main/docs/tokens-reference.md).
46
+
47
+ ## Deploy = SSH `compose pull && up -d`
48
+
49
+ `greenlight deploy <tool>` (docker) writes `DOCKER_SSH_KEY` to a temp `600` file and runs
50
+ `ssh … "cd <remoteDir> && docker compose pull && docker compose up -d"` (default remote dir
51
+ `greenlight/<name>`; override with `DOCKER_COMPOSE_DIR`). The adapter does **not** build — the image
52
+ is already on GHCR. Verify prod gates the signal on the new image actually serving.
53
+
54
+ ## Verify
55
+ Usually an **MCP server**: `mode: mcp`, connect at `<name>.<domain>/mcp`. Keepalive health-checks
56
+ `target: docker` (a plain GET); on an outage the keepalive Worker dispatches `remediate-<name>`,
57
+ which simply **re-runs the SSH deploy** (no Always-Free box to recreate).
58
+
59
+ ## Gotchas
60
+ - **The host is yours — Greenlight won't provision or patch it.** Keep Docker running and the
61
+ compose present; `up -d` + a `restart: unless-stopped` policy survives reboots.
62
+ - **Tunnel token lives on the host.** The `<name>_tunnel_token` output must reach the host's
63
+ cloudflared (its compose env), set up once — the deploy step doesn't push it.
64
+ - **SSH key hygiene.** Use a dedicated deploy key with least privilege; rotate it on a schedule
65
+ (nothing automated rotates it). `StrictHostKeyChecking=accept-new` trusts the host on first
66
+ connect — pin a known_hosts entry if you want stricter.
67
+ - **Not free, but stable.** This is the deliberate trade vs oci: you pay for a host that doesn't get
68
+ idle-reclaimed. For ephemeral/throwaway MCP, `target: workers` is still the cheaper path.
@@ -1,7 +1,9 @@
1
1
  import {
2
+ runScenario,
2
3
  verifyAgentWeb
3
- } from "./chunk-IYEIZYI5.js";
4
+ } from "./chunk-2LAYOVFP.js";
4
5
  import "./chunk-QFKE5JKC.js";
5
6
  export {
7
+ runScenario,
6
8
  verifyAgentWeb
7
9
  };
package/dist/bin.js CHANGED
@@ -9,12 +9,12 @@ import {
9
9
  scanSqlFiles,
10
10
  toExportResult,
11
11
  verifyAll
12
- } from "./chunk-FZH2YQPJ.js";
13
- import "./chunk-HX7VA25D.js";
12
+ } from "./chunk-NFNVF4HV.js";
13
+ import "./chunk-UMY5ZDOQ.js";
14
14
  import "./chunk-N3IKUCSF.js";
15
15
  import "./chunk-KP3Y6WRU.js";
16
- import "./chunk-IYEIZYI5.js";
17
- import "./chunk-3A6F2JNP.js";
16
+ import "./chunk-2LAYOVFP.js";
17
+ import "./chunk-C6NJHCRI.js";
18
18
  import "./chunk-QFKE5JKC.js";
19
19
 
20
20
  // src/commands/add.ts
@@ -150,6 +150,7 @@ function serializeTool(t) {
150
150
  parts.push(`tokenOverrides: { ${ov} }`);
151
151
  }
152
152
  if (t.dataShareWith !== void 0) parts.push(`dataShareWith: ${q(t.dataShareWith)}`);
153
+ if (t.requireMigrationApproval) parts.push("requireMigrationApproval: true");
153
154
  return ` { ${parts.join(", ")} },`;
154
155
  }
155
156
  function serializeConfig(c) {
@@ -198,7 +199,8 @@ function addTool(config, t) {
198
199
  ...t.preview ? { preview: t.preview } : {},
199
200
  ...t.tokens?.length ? { tokens: t.tokens } : {},
200
201
  ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {},
201
- ...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {}
202
+ ...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {},
203
+ ...t.requireMigrationApproval ? { requireMigrationApproval: true } : {}
202
204
  }
203
205
  ]
204
206
  };
@@ -227,7 +229,8 @@ function upsertTool(config, t) {
227
229
  ...t.preview ? { preview: t.preview } : {},
228
230
  ...t.tokens?.length ? { tokens: t.tokens } : {},
229
231
  ...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {},
230
- ...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {}
232
+ ...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {},
233
+ ...t.requireMigrationApproval ? { requireMigrationApproval: true } : {}
231
234
  };
232
235
  const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
233
236
  const result = ConfigSchema.safeParse({ ...config, tools });
@@ -302,7 +305,14 @@ async function loadVerifySpecAt(relPath) {
302
305
  const path = resolve2(process.cwd(), relPath);
303
306
  if (!existsSync2(path)) return null;
304
307
  const jiti = createJiti(import.meta.url);
305
- const mod = await jiti.import(path);
308
+ let mod;
309
+ try {
310
+ mod = await jiti.import(path);
311
+ } catch (e) {
312
+ throw new Error(
313
+ `Could not load verify spec ${relPath}: ${e instanceof Error ? e.message : String(e)}`
314
+ );
315
+ }
306
316
  const def = "default" in mod ? mod.default : mod;
307
317
  if (Array.isArray(def)) return def.map((s) => asSpec(relPath, s));
308
318
  return asSpec(relPath, def);
@@ -323,7 +333,7 @@ var PACKS = [
323
333
  always: true,
324
334
  // the zone/DNS provider + Workers (keepalive) for every Greenlight setup
325
335
  appliesTo: () => true,
326
- guide: "docs/provider-tokens.md \u2014 CLOUDFLARE_API_TOKEN (Workers Scripts:Edit + Zone DNS:Edit + Cloudflare Tunnel:Edit for oci tools)",
336
+ guide: "docs/provider-tokens.md \u2014 CLOUDFLARE_API_TOKEN (Workers Scripts:Edit + Zone DNS:Edit + Cloudflare Tunnel:Edit for oci/docker tools)",
327
337
  setupUrl: "https://dash.cloudflare.com/profile/api-tokens",
328
338
  tokens: [
329
339
  {
@@ -333,7 +343,7 @@ var PACKS = [
333
343
  "Account \xB7 Workers Scripts \xB7 Edit",
334
344
  "Zone \xB7 DNS \xB7 Edit",
335
345
  "Account \xB7 Account Settings \xB7 Read",
336
- "Account \xB7 Cloudflare Tunnel \xB7 Edit (only if a tool uses target: oci)"
346
+ "Account \xB7 Cloudflare Tunnel \xB7 Edit (only if a tool uses target: oci or docker)"
337
347
  ],
338
348
  verify: async (t) => {
339
349
  const auth = { Authorization: `Bearer ${t}` };
@@ -569,6 +579,59 @@ var PACKS = [
569
579
  skill: "provider-oci",
570
580
  // DNS + tunnel + network (VCN/subnet) + compute; deploy = restart.
571
581
  tfModules: ["tool", "tunnel", "oci-network", "oci-container-instance"]
582
+ },
583
+ {
584
+ id: "docker",
585
+ name: "Docker host (SSH)",
586
+ appliesTo: (t) => t.target === "docker",
587
+ // A host YOU own (VPS / homelab) — a stable alternative to OCI's idle-reclaimed free tier for
588
+ // stateful tools. Build → GHCR (the tool's own CI, like oci); deploy = SSH `docker compose
589
+ // pull && up -d`; ingress via the same Cloudflare tunnel (a cloudflared service in the compose).
590
+ guide: "docs/provider-tokens.md \u2014 DOCKER_SSH_* (a host you own; build\u2192GHCR, deploy = ssh docker compose up)",
591
+ // No web console (SSH to a host you own) — point at the deep guide for how to mint a deploy key.
592
+ setupUrl: "https://github.com/RTrentJones/greenlight/blob/main/docs/provider-tokens.md",
593
+ tokens: [
594
+ // SSH connection facts. perTool so multiple docker tools can live on DIFFERENT hosts without
595
+ // colliding (the deploy workflow maps DOCKER_SSH_*_<TOOL> → the unsuffixed env the adapter reads).
596
+ // No cheap verify — SSH reachability isn't a bearer fetch.
597
+ {
598
+ envVar: "DOCKER_SSH_HOST",
599
+ label: "SSH host (hostname or IP of the Docker host)",
600
+ perTool: true
601
+ },
602
+ {
603
+ envVar: "DOCKER_SSH_KEY",
604
+ label: "SSH private key (PEM content) for the deploy user",
605
+ perTool: true
606
+ },
607
+ {
608
+ envVar: "DOCKER_SSH_USER",
609
+ label: "SSH user (default: root)",
610
+ optional: true,
611
+ perTool: true
612
+ },
613
+ { envVar: "DOCKER_SSH_PORT", label: "SSH port (default: 22)", optional: true, perTool: true },
614
+ // Option-B event-driven deploy (same model as oci): the TOOL repo builds→GHCR→dispatches, the
615
+ // WRAPPER's deploy listener SSHes the host + posts status back. dispatch → on the TOOL repo;
616
+ // status → on the WRAPPER (per-tool so multiple tools' status tokens don't collide).
617
+ {
618
+ envVar: "GREENLIGHT_DISPATCH_TOKEN",
619
+ label: "GitHub PAT, Contents:write on the WRAPPER (TOOL repo fires the deploy dispatch)",
620
+ optional: true,
621
+ setupUrl: "https://github.com/settings/personal-access-tokens/new"
622
+ },
623
+ {
624
+ envVar: "GREENLIGHT_STATUS_TOKEN",
625
+ label: "GitHub PAT, Commit statuses:write on the TOOL (WRAPPER posts deploy status back)",
626
+ optional: true,
627
+ perTool: true,
628
+ setupUrl: "https://github.com/settings/personal-access-tokens/new"
629
+ }
630
+ ],
631
+ skill: "provider-docker",
632
+ // DNS + tunnel only — the host (VM/homelab) is user-owned, so there is NO compute Terraform
633
+ // (unlike oci). The host runs the GHCR image + a cloudflared sidecar via docker compose.
634
+ tfModules: ["tool", "tunnel"]
572
635
  }
573
636
  ];
574
637
  function secretKeyFor(tok, toolName, overrides) {
@@ -602,7 +665,7 @@ function tokensForTool(tool) {
602
665
  }
603
666
 
604
667
  // src/version.ts
605
- var MODULE_REF = "v0.6.0";
668
+ var MODULE_REF = "v0.7.0";
606
669
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
607
670
  function moduleSource(module, ref = MODULE_REF) {
608
671
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -610,8 +673,25 @@ function moduleSource(module, ref = MODULE_REF) {
610
673
 
611
674
  // src/tf-emit.ts
612
675
  var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
676
+ function assertHclSafe(fields) {
677
+ for (const [field, value] of Object.entries(fields)) {
678
+ if (value && /["\\\n]/.test(value)) {
679
+ throw new Error(
680
+ `refusing to emit Terraform: ${field} contains an invalid character ("${value}")`
681
+ );
682
+ }
683
+ }
684
+ }
613
685
  function emitToolTf(opts) {
614
686
  const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
687
+ assertHclSafe({
688
+ name,
689
+ domain,
690
+ slug: opts.slug,
691
+ dataShareWith: opts.dataShareWith,
692
+ "tokenOverrides.SUPABASE_ACCESS_TOKEN": opts.tokenOverrides?.SUPABASE_ACCESS_TOKEN,
693
+ "tokenOverrides.NEON_API_KEY": opts.tokenOverrides?.NEON_API_KEY
694
+ });
615
695
  if (lane === "agent") {
616
696
  const suffix = data && data !== "none" ? `/${data}` : "";
617
697
  return `# ${name} \u2014 agent/${target}${suffix}, emitted by \`greenlight add\`.
@@ -626,6 +706,7 @@ function emitToolTf(opts) {
626
706
  const useNeon = data === "neon";
627
707
  const useVercel = target === "vercel";
628
708
  const useOci = target === "oci";
709
+ const useDocker = target === "docker";
629
710
  const supabaseOverride = opts.tokenOverrides?.SUPABASE_ACCESS_TOKEN;
630
711
  const neonOverride = opts.tokenOverrides?.NEON_API_KEY;
631
712
  const neonOwner = opts.dataShareWith ?? name;
@@ -633,6 +714,7 @@ function emitToolTf(opts) {
633
714
  const blocks = [];
634
715
  const assumes = ["var.cloudflare_zone_id"];
635
716
  if (useOci) assumes.push("var.cloudflare_account_id", "local.oci_compartment_id");
717
+ if (useDocker) assumes.push("var.cloudflare_account_id");
636
718
  if (useSupabase) assumes.push("var.supabase_organization_id");
637
719
  const ghcrOwner = (slug.split("/")[0] ?? "owner").toLowerCase();
638
720
  blocks.push(
@@ -810,7 +892,23 @@ variable "${name}_image" {
810
892
  description = "GHCR image for ${name} (built + pushed by ${slug}'s own CI)."
811
893
  }`);
812
894
  }
813
- blocks.push(`# Subdomain DNS \u2014 CNAME ${name}/beta.${name} \u2192 ${useVercel ? "cname.vercel-dns.com" : useOci ? "the tunnel" : "the target"}.
895
+ if (useDocker) {
896
+ blocks.push(`# Self-hosted Docker target \u2014 a host YOU own (VPS/homelab). Only the Cloudflare tunnel + DNS are
897
+ # Terraform here; the COMPUTE is the host (not managed by Greenlight). The tool's own CI builds +
898
+ # pushes the image to GHCR; deploy = SSH \`docker compose pull && up -d\` (the docker adapter), with a
899
+ # cloudflared service in the compose using \`${name}_tunnel_token\`. The tunnel routes ${name}.${domain}
900
+ # \u2192 the container at localhost:${port}.
901
+ module "${name}_tunnel" {
902
+ source = "${moduleSource("tunnel", ref)}"
903
+
904
+ account_id = var.cloudflare_account_id
905
+ name = "${name}-tunnel"
906
+ ingress = [
907
+ { hostname = "${name}.${domain}", service = "http://localhost:${port}" },
908
+ ]
909
+ }`);
910
+ }
911
+ blocks.push(`# Subdomain DNS \u2014 CNAME ${name}/beta.${name} \u2192 ${useVercel ? "cname.vercel-dns.com" : useOci || useDocker ? "the tunnel" : "the target"}.
814
912
  module "${name}_dns" {
815
913
  source = "${moduleSource("tool", ref)}"
816
914
 
@@ -821,7 +919,7 @@ module "${name}_dns" {
821
919
  lane = "${lane}"
822
920
  target = "${target}"
823
921
  data = "${data}"
824
- envs = [${envList}]${useOci ? `
922
+ envs = [${envList}]${useOci || useDocker ? `
825
923
  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" : ""}
826
924
  }`);
827
925
  if (useSupabase) {
@@ -831,16 +929,22 @@ module "${name}_dns" {
831
929
  }
832
930
  const outputs = useVercel ? `output "${name}_prod_url" { value = module.${name}_vercel.prod_url }
833
931
  output "${name}_beta_url" { value = module.${name}_vercel.beta_url }` : `output "${name}_prod_url" { value = module.${name}_dns.prod_url }`;
834
- blocks.push(
835
- useOci ? `${outputs}
836
- output "${name}_tunnel_token" {
932
+ const tunnelTokenOutput = `output "${name}_tunnel_token" {
837
933
  value = module.${name}_tunnel.token
838
934
  sensitive = true
839
- }
935
+ }`;
936
+ blocks.push(
937
+ useOci ? `${outputs}
938
+ ${tunnelTokenOutput}
840
939
  output "${name}_container_instance_id" {
841
940
  value = module.${name}_instance.container_instance_id
842
941
  description = "Set as OCI_CONTAINER_INSTANCE_OCID so \`greenlight deploy ${name}\` restarts it."
843
- }` : outputs
942
+ }` : useDocker ? (
943
+ // The host's cloudflared needs this token (passed to the compose at deploy time); no
944
+ // container_instance_id (the host is user-owned, not an OCI instance Greenlight restarts).
945
+ `${outputs}
946
+ ${tunnelTokenOutput}`
947
+ ) : outputs
844
948
  );
845
949
  return `${hcl(blocks.join("\n\n"))}
846
950
  `;
@@ -1121,6 +1225,14 @@ function setGitHubSecret(repo, env, key, value) {
1121
1225
  throw new Error(`failed to set ${key}${detail ? `: ${detail}` : " (check `gh auth status`)"}`);
1122
1226
  }
1123
1227
  }
1228
+ function appSecretsToGather(entry, packs) {
1229
+ const packKeys = new Set(
1230
+ packs.flatMap(
1231
+ (p) => p.tokens.map((t) => secretKeyFor(t, entry.name ?? "", entry.tokenOverrides))
1232
+ )
1233
+ );
1234
+ return (entry.tokens ?? []).filter((k) => !packKeys.has(k));
1235
+ }
1124
1236
  async function gatherSecrets(name, repo, env, prefill) {
1125
1237
  const { config } = await loadManifest();
1126
1238
  const entry = resolveEntry(config, name);
@@ -1179,6 +1291,29 @@ async function gatherSecrets(name, repo, env, prefill) {
1179
1291
  pushed++;
1180
1292
  }
1181
1293
  }
1294
+ const appSecrets = appSecretsToGather(entry, packs);
1295
+ if (appSecrets.length) {
1296
+ console.log(`\u2500\u2500 ${name} (app secrets)`);
1297
+ for (const key of appSecrets) {
1298
+ const pre = prefill?.get(key);
1299
+ if (pre) {
1300
+ setGitHubSecret(repo, env, key, pre);
1301
+ console.log(` \u2714 ${existing?.has(key) ? "overrode" : "pushed"} ${key} \u2190 prefill`);
1302
+ pushed++;
1303
+ continue;
1304
+ }
1305
+ const state = existing ? existing.has(key) ? " [already set]" : " [not set]" : "";
1306
+ const value = await prompt.ask(` ${key}${state}
1307
+ value: `);
1308
+ if (!value) {
1309
+ console.log(existing?.has(key) ? " \xB7 kept existing" : " \xB7 skipped");
1310
+ continue;
1311
+ }
1312
+ setGitHubSecret(repo, env, key, value);
1313
+ console.log(` \u2714 ${existing?.has(key) ? "overrode" : "pushed"} ${key} \u2192 ${repo}`);
1314
+ pushed++;
1315
+ }
1316
+ }
1182
1317
  } finally {
1183
1318
  prompt.close();
1184
1319
  }
@@ -1714,7 +1849,19 @@ function ociDeployAndVerifySteps(name) {
1714
1849
  # posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
1715
1850
  run: pnpm exec greenlight verify ${name} --env prod`;
1716
1851
  }
1717
- function deployListenerYml(name, toolRepo) {
1852
+ function dockerDeployAndVerifySteps(name) {
1853
+ const SUF = name.toUpperCase().replace(/-/g, "_");
1854
+ return ` - name: Deploy (SSH \`docker compose pull && up -d\` on the host)
1855
+ env:
1856
+ DOCKER_SSH_HOST: \${{ secrets.DOCKER_SSH_HOST_${SUF} }}
1857
+ DOCKER_SSH_USER: \${{ secrets.DOCKER_SSH_USER_${SUF} }}
1858
+ DOCKER_SSH_KEY: \${{ secrets.DOCKER_SSH_KEY_${SUF} }}
1859
+ DOCKER_SSH_PORT: \${{ secrets.DOCKER_SSH_PORT_${SUF} }}
1860
+ run: pnpm exec greenlight deploy ${name} --env prod
1861
+ - name: Verify prod (gate the signal on real health, not just the restart)
1862
+ run: pnpm exec greenlight verify ${name} --env prod`;
1863
+ }
1864
+ function deployListenerYml(name, toolRepo, deploySteps = ociDeployAndVerifySteps(name)) {
1718
1865
  return `name: greenlight-deploy-${name}
1719
1866
 
1720
1867
  # Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
@@ -1738,7 +1885,7 @@ jobs:
1738
1885
  - uses: actions/checkout@v4
1739
1886
  - uses: jdx/mise-action@v2
1740
1887
  - run: pnpm install --frozen-lockfile
1741
- ${ociDeployAndVerifySteps(name)}
1888
+ ${deploySteps}
1742
1889
  - name: Report status back to ${toolRepo}
1743
1890
  if: \${{ always() && github.event.client_payload.sha != '' }}
1744
1891
  env:
@@ -1811,6 +1958,83 @@ ${ociDeployAndVerifySteps(name)}
1811
1958
  --label keepalive || true
1812
1959
  `;
1813
1960
  }
1961
+ function migrateWorkflowYml(name) {
1962
+ const SUF = name.toUpperCase().replace(/-/g, "_");
1963
+ return `name: greenlight-migrate-${name}
1964
+
1965
+ # Manual-approval prod DB migration gate. Set the required reviewers on the \`${name}-prod\`
1966
+ # environment (the tool module's \`prod_reviewers\`, or repo Settings \u2192 Environments) \u2014 GitHub pauses
1967
+ # the job below until one approves. Move migrate OUT of the app/platform build into this gated job.
1968
+ on:
1969
+ workflow_dispatch:
1970
+ # Tip: gate the prod deploy on this \u2014 call it first, or make the deploy \`needs:\` a green run here.
1971
+
1972
+ permissions:
1973
+ contents: read
1974
+
1975
+ concurrency:
1976
+ group: migrate-${name}
1977
+ cancel-in-progress: false
1978
+
1979
+ jobs:
1980
+ migrate:
1981
+ runs-on: ubuntu-latest
1982
+ # Required-reviewer gate: GitHub pauses here until a reviewer approves the ${name}-prod environment.
1983
+ environment: ${name}-prod
1984
+ steps:
1985
+ - uses: actions/checkout@v4
1986
+ - uses: jdx/mise-action@v2
1987
+ - run: pnpm install --frozen-lockfile
1988
+ - name: Scan migrations (block destructive SQL)
1989
+ run: pnpm exec greenlight migrations scan --strict
1990
+ - name: Apply prod migrations
1991
+ env:
1992
+ # Prod DIRECT (non-pooled) connection \u2014 set as a secret. Neon/Supabase expose a DIRECT_URL.
1993
+ DIRECT_URL: \${{ secrets.DIRECT_URL_${SUF} }}
1994
+ # TODO: replace with your migrate command against $DIRECT_URL, e.g.
1995
+ # pnpm drizzle-kit migrate | pnpm prisma migrate deploy
1996
+ run: |
1997
+ echo "::error::set the migrate command in greenlight-migrate-${name}.yml (drizzle-kit migrate / prisma migrate deploy)"
1998
+ exit 1
1999
+ `;
2000
+ }
2001
+ function dockerRemediateYml(name) {
2002
+ return `name: greenlight-remediate-${name}
2003
+
2004
+ # Auto-heal: the keepalive Worker dispatches remediate-${name} when ${name} (docker) is unreachable.
2005
+ on:
2006
+ repository_dispatch:
2007
+ types: [remediate-${name}]
2008
+ workflow_dispatch:
2009
+
2010
+ permissions:
2011
+ contents: read
2012
+ issues: write
2013
+
2014
+ # Same group as greenlight-deploy-${name}: a self-heal never overlaps a deploy or another heal.
2015
+ concurrency:
2016
+ group: deploy-${name}
2017
+ cancel-in-progress: false
2018
+
2019
+ jobs:
2020
+ remediate:
2021
+ runs-on: ubuntu-latest
2022
+ steps:
2023
+ - uses: actions/checkout@v4
2024
+ - uses: jdx/mise-action@v2
2025
+ - run: pnpm install --frozen-lockfile
2026
+ ${dockerDeployAndVerifySteps(name)}
2027
+ - name: Escalate if the self-heal failed
2028
+ if: \${{ failure() }}
2029
+ env:
2030
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2031
+ run: |
2032
+ gh issue create --repo \${{ github.repository }} \\
2033
+ --title "remediate-${name}: self-heal FAILED" \\
2034
+ --body "Automatic remediation for ${name} (reason: \${{ github.event.client_payload.reason }}) did not bring prod back. Manual attention needed." \\
2035
+ --label keepalive || true
2036
+ `;
2037
+ }
1814
2038
  function verifyWorkflowYml(name) {
1815
2039
  return `name: greenlight-verify
1816
2040
 
@@ -1901,7 +2125,7 @@ async function adoptCommand(args) {
1901
2125
  const name = args[0];
1902
2126
  if (!name || name.startsWith("-")) {
1903
2127
  throw new Error(
1904
- "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)."
2128
+ "usage: greenlight adopt <name> --repo <url|path> --lane <l> --target <t> [--data --auth --envs] [--require-migration-approval] [--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)."
1905
2129
  );
1906
2130
  }
1907
2131
  const repoArg = flag3(args, "--repo");
@@ -1912,6 +2136,7 @@ async function adoptCommand(args) {
1912
2136
  const data = flag3(args, "--data") ?? "none";
1913
2137
  const auth = flag3(args, "--auth") ?? "none";
1914
2138
  const envs = flag3(args, "--envs")?.split(",") ?? ["beta", "prod"];
2139
+ const requireMigrationApproval = args.includes("--require-migration-approval");
1915
2140
  const { path: regPath, config: reg } = await loadManifest();
1916
2141
  if (regPath.endsWith(".example.ts")) {
1917
2142
  throw new Error(
@@ -1922,12 +2147,36 @@ async function adoptCommand(args) {
1922
2147
  throw new Error('"blog" is the apex site, not an adopted tool');
1923
2148
  }
1924
2149
  const domain = flag3(args, "--domain") ?? reg.domain;
1925
- const ctx = { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath };
2150
+ const ctx = {
2151
+ name,
2152
+ repoArg,
2153
+ lane,
2154
+ target,
2155
+ data,
2156
+ auth,
2157
+ envs,
2158
+ domain,
2159
+ requireMigrationApproval,
2160
+ reg,
2161
+ regPath
2162
+ };
1926
2163
  if (args.includes("--standalone")) return adoptStandalone(ctx);
1927
2164
  return adoptWrapper(ctx);
1928
2165
  }
1929
2166
  async function adoptWrapper(ctx) {
1930
- const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
2167
+ const {
2168
+ name,
2169
+ repoArg,
2170
+ lane,
2171
+ target,
2172
+ data,
2173
+ auth,
2174
+ envs,
2175
+ domain,
2176
+ requireMigrationApproval,
2177
+ reg,
2178
+ regPath
2179
+ } = ctx;
1931
2180
  const cwd = process.cwd();
1932
2181
  const toolRel = `tools/${name}`;
1933
2182
  const dest = resolve6(cwd, toolRel);
@@ -1957,17 +2206,18 @@ async function adoptWrapper(ctx) {
1957
2206
  dir: toolRel,
1958
2207
  external: true,
1959
2208
  adopted: true,
1960
- // oci has no built-in local serve — scaffold a `preview` descriptor so the uniform local gate
1961
- // (`greenlight preview <name>`) works. Default: a docker `preview` profile matching the prod
1962
- // transport (the tool adds that profile to its compose). Edit the command/port/path to fit.
1963
- ...target === "oci" ? {
2209
+ // Container targets (oci/docker) have no built-in local serve — scaffold a `preview` descriptor
2210
+ // so the uniform local gate (`greenlight preview <name>`) works. Default: a docker `preview`
2211
+ // profile matching the prod transport (the tool adds that profile to its compose). Edit to fit.
2212
+ ...target === "oci" || target === "docker" ? {
1964
2213
  preview: {
1965
2214
  command: "docker compose --profile preview up",
1966
2215
  teardown: "docker compose --profile preview down -v",
1967
2216
  port: 8e3,
1968
2217
  path: lane === "mcp" ? "/mcp" : ""
1969
2218
  }
1970
- } : {}
2219
+ } : {},
2220
+ ...requireMigrationApproval ? { requireMigrationApproval: true } : {}
1971
2221
  });
1972
2222
  writeFileSync3(regPath, serializeConfig(nextReg));
1973
2223
  console.log(
@@ -1992,16 +2242,17 @@ async function adoptWrapper(ctx) {
1992
2242
  }
1993
2243
  materializeAgentKit(dest, { lane, target, data });
1994
2244
  addGreenlightScript(dest);
1995
- if (target === "oci") {
2245
+ if (target === "oci" || target === "docker") {
1996
2246
  const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
2247
+ const deploySteps = target === "docker" ? dockerDeployAndVerifySteps(name) : ociDeployAndVerifySteps(name);
1997
2248
  writeIfAbsent(
1998
2249
  join2(cwd, `.github/workflows/greenlight-deploy-${name}.yml`),
1999
- deployListenerYml(name, slug),
2250
+ deployListenerYml(name, slug, deploySteps),
2000
2251
  `.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
2001
2252
  );
2002
2253
  writeIfAbsent(
2003
2254
  join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
2004
- remediateYml(name),
2255
+ target === "docker" ? dockerRemediateYml(name) : remediateYml(name),
2005
2256
  `.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
2006
2257
  );
2007
2258
  writeIfAbsent(
@@ -2022,6 +2273,13 @@ async function adoptWrapper(ctx) {
2022
2273
  `${toolRel}/.github/workflows/greenlight-verify.yml (verify on Vercel deployment_status)`
2023
2274
  );
2024
2275
  }
2276
+ if (requireMigrationApproval && (data === "supabase" || data === "neon")) {
2277
+ writeIfAbsent(
2278
+ join2(dest, `.github/workflows/greenlight-migrate-${name}.yml`),
2279
+ migrateWorkflowYml(name),
2280
+ `${toolRel}/.github/workflows/greenlight-migrate-${name}.yml (gated prod migration)`
2281
+ );
2282
+ }
2025
2283
  console.log(`
2026
2284
  Next:
2027
2285
  (in the tool repo) commit the Greenlight kit + build workflow so they travel with the submodule:
@@ -2031,14 +2289,30 @@ Next:
2031
2289
  git commit && git push # CI (infra.yml) applies. Tool's CI builds; wrapper deploys.${target === "oci" ? `
2032
2290
  Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # TF_VAR_OCI_* + GREENLIGHT_STATUS_TOKEN
2033
2291
  greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
2034
- The instance OCID is auto-resolved by the deploy workflow (by display name) \u2014 nothing to set.` : target === "vercel" ? `
2292
+ The instance OCID is auto-resolved by the deploy workflow (by display name) \u2014 nothing to set.` : target === "docker" ? `
2293
+ Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # DOCKER_SSH_* + GREENLIGHT_STATUS_TOKEN
2294
+ greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
2295
+ On the host (one-time): a docker-compose with the GHCR image + a cloudflared service using the
2296
+ ${name}_tunnel_token output; the deploy listener SSHes it and runs \`docker compose pull && up -d\`.` : target === "vercel" ? `
2035
2297
  Deploy is Vercel's git integration (no wrapper deploy). The tool's greenlight-verify.yml verifies
2036
2298
  each deployment (deployment_status). Optional secrets on ${slug}:
2037
2299
  \xB7 VERCEL_AUTOMATION_BYPASS_SECRET_${name.toUpperCase().replace(/-/g, "_")} (Vercel \u2192 project \u2192 Deployment Protection \u2192 Bypass for Automation) \u2192 verify asserts 200, not 401
2038
2300
  \xB7 ANTHROPIC_API_KEY \u2192 enables the agent-web scenarios in verify/${name}.config.ts (absent \u2192 api gate alone)` : ""}`);
2039
2301
  }
2040
2302
  async function adoptStandalone(ctx) {
2041
- const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
2303
+ const {
2304
+ name,
2305
+ repoArg,
2306
+ lane,
2307
+ target,
2308
+ data,
2309
+ auth,
2310
+ envs,
2311
+ domain,
2312
+ requireMigrationApproval,
2313
+ reg,
2314
+ regPath
2315
+ } = ctx;
2042
2316
  const repo = resolve6(process.cwd(), repoArg);
2043
2317
  if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
2044
2318
  const regVendor = resolve6(process.cwd(), "vendor");
@@ -2050,7 +2324,17 @@ async function adoptStandalone(ctx) {
2050
2324
  }
2051
2325
  console.log(`adopting "${name}" (${lane}/${target}) into ${repo} (standalone)
2052
2326
  `);
2053
- const toolEntry = { name, lane, target, data, auth, envs, dir: ".", adopted: true };
2327
+ const toolEntry = {
2328
+ name,
2329
+ lane,
2330
+ target,
2331
+ data,
2332
+ auth,
2333
+ envs,
2334
+ dir: ".",
2335
+ adopted: true,
2336
+ ...requireMigrationApproval ? { requireMigrationApproval: true } : {}
2337
+ };
2054
2338
  const toolConfig = addTool({ domain, alerts: { sink: "github-issue" }, tools: [] }, toolEntry);
2055
2339
  writeIfAbsent(
2056
2340
  join2(repo, "greenlight.config.ts"),
@@ -2088,6 +2372,13 @@ async function adoptStandalone(ctx) {
2088
2372
  ".github/workflows/greenlight-promote.yml"
2089
2373
  );
2090
2374
  writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
2375
+ if (requireMigrationApproval && (data === "supabase" || data === "neon")) {
2376
+ writeIfAbsent(
2377
+ join2(repo, `.github/workflows/greenlight-migrate-${name}.yml`),
2378
+ migrateWorkflowYml(name),
2379
+ `.github/workflows/greenlight-migrate-${name}.yml (gated prod migration)`
2380
+ );
2381
+ }
2091
2382
  materializeAgentKit(repo, { lane, target, data });
2092
2383
  writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
2093
2384
  writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
@@ -2099,7 +2390,8 @@ async function adoptStandalone(ctx) {
2099
2390
  auth,
2100
2391
  envs,
2101
2392
  external: true,
2102
- adopted: true
2393
+ adopted: true,
2394
+ ...requireMigrationApproval ? { requireMigrationApproval: true } : {}
2103
2395
  });
2104
2396
  writeFileSync3(regPath, serializeConfig(nextReg));
2105
2397
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
@@ -2237,6 +2529,8 @@ async function configCommand() {
2237
2529
 
2238
2530
  // ../packages/adapters/src/index.ts
2239
2531
  import { execFileSync as execFileSync3 } from "child_process";
2532
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync as writeFileSync6 } from "fs";
2533
+ import { tmpdir } from "os";
2240
2534
  import { join as join4 } from "path";
2241
2535
  function run(cmd, args, cwd, extraEnv) {
2242
2536
  execFileSync3(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...extraEnv } });
@@ -2298,6 +2592,63 @@ function ociAdapter(ctx) {
2298
2592
  }
2299
2593
  };
2300
2594
  }
2595
+ function dockerConfig(name, source = process.env) {
2596
+ return {
2597
+ host: source.DOCKER_SSH_HOST,
2598
+ user: source.DOCKER_SSH_USER || "root",
2599
+ port: source.DOCKER_SSH_PORT || "22",
2600
+ remoteDir: source.DOCKER_COMPOSE_DIR || `greenlight/${name ?? "app"}`,
2601
+ key: source.DOCKER_SSH_KEY
2602
+ };
2603
+ }
2604
+ function sshDeployArgs(cfg, identityPath) {
2605
+ const remoteCmd = `cd ${cfg.remoteDir} && docker compose pull && docker compose up -d`;
2606
+ return [
2607
+ "-i",
2608
+ identityPath,
2609
+ "-p",
2610
+ cfg.port,
2611
+ "-o",
2612
+ "StrictHostKeyChecking=accept-new",
2613
+ "-o",
2614
+ "BatchMode=yes",
2615
+ `${cfg.user}@${cfg.host}`,
2616
+ remoteCmd
2617
+ ];
2618
+ }
2619
+ function dockerAdapter(ctx) {
2620
+ const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
2621
+ return {
2622
+ target: "docker",
2623
+ async build() {
2624
+ return { artifactDir: "." };
2625
+ },
2626
+ async deploy(_toolDir, env) {
2627
+ const cfg = dockerConfig(ctx.name);
2628
+ if (!cfg.host) throw new Error("docker deploy needs DOCKER_SSH_HOST (the host you own)");
2629
+ if (!cfg.key) {
2630
+ throw new Error("docker deploy needs DOCKER_SSH_KEY (the deploy user's private key)");
2631
+ }
2632
+ const dir = mkdtempSync(join4(tmpdir(), "gl-ssh-"));
2633
+ const keyPath = join4(dir, "id");
2634
+ try {
2635
+ writeFileSync6(keyPath, cfg.key.endsWith("\n") ? cfg.key : `${cfg.key}
2636
+ `);
2637
+ chmodSync(keyPath, 384);
2638
+ run("ssh", sshDeployArgs(cfg, keyPath), ".");
2639
+ } finally {
2640
+ rmSync(dir, { recursive: true, force: true });
2641
+ }
2642
+ return { url: url(env) };
2643
+ },
2644
+ url,
2645
+ async teardown() {
2646
+ throw new Error(
2647
+ "docker teardown is on the host \u2014 `ssh \u2026 docker compose down` in the tool dir."
2648
+ );
2649
+ }
2650
+ };
2651
+ }
2301
2652
  function vercelSkeletonAdapter(ctx) {
2302
2653
  const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
2303
2654
  const notWired = () => {
@@ -2319,6 +2670,8 @@ function createAdapter(target, ctx) {
2319
2670
  return workersAdapter(ctx);
2320
2671
  case "oci":
2321
2672
  return ociAdapter(ctx);
2673
+ case "docker":
2674
+ return dockerAdapter(ctx);
2322
2675
  case "vercel":
2323
2676
  return vercelSkeletonAdapter(ctx);
2324
2677
  }
@@ -2340,7 +2693,7 @@ async function deployCommand(args) {
2340
2693
  }
2341
2694
  const { config } = await loadManifest();
2342
2695
  const entry = resolveEntry(config, name);
2343
- if (entry.external && entry.target !== "oci") {
2696
+ if (entry.external && entry.target !== "oci" && entry.target !== "docker") {
2344
2697
  throw new Error(`"${name}" is external (registry pointer) \u2014 deploy it from its own repo`);
2345
2698
  }
2346
2699
  const adapter = createAdapter(entry.target, { domain: config.domain, name: entry.name });
@@ -2501,6 +2854,18 @@ function conformanceChecks(t, root) {
2501
2854
  });
2502
2855
  }
2503
2856
  }
2857
+ if (t.requireMigrationApproval) {
2858
+ const migrateWf = `greenlight-migrate-${t.name}.yml`;
2859
+ const hasWf = [
2860
+ join6(root, toolDir, ".github/workflows", migrateWf),
2861
+ join6(root, ".github/workflows", migrateWf)
2862
+ ].some((p) => existsSync9(p));
2863
+ out.push({
2864
+ name: `${t.name}: migration approval`,
2865
+ status: hasWf ? "ok" : "warn",
2866
+ detail: hasWf ? `${migrateWf} present \u2014 set required reviewers on the ${t.name}-prod environment (prod_reviewers)` : `requireMigrationApproval set but ${migrateWf} is missing \u2014 re-run adopt with --require-migration-approval, and set prod_reviewers on the ${t.name}-prod environment`
2867
+ });
2868
+ }
2504
2869
  return out;
2505
2870
  }
2506
2871
  function versionDriftCheck(root) {
@@ -2567,11 +2932,13 @@ function runDoctor(config, root) {
2567
2932
  }
2568
2933
  checks.push(...conformanceChecks(t, root));
2569
2934
  }
2570
- const needsKeepalive = config.tools.filter((t) => t.data === "supabase" || t.target === "oci");
2935
+ const needsKeepalive = config.tools.filter(
2936
+ (t) => t.data === "supabase" || t.target === "oci" || t.target === "docker"
2937
+ );
2571
2938
  checks.push({
2572
2939
  name: "keepalive coverage",
2573
2940
  status: needsKeepalive.length > 0 ? "ok" : "skip",
2574
- detail: needsKeepalive.length > 0 ? needsKeepalive.map((t) => `${t.name} (${t.data === "supabase" ? "supabase" : "oci"})`).join(", ") : "no data:supabase / target:oci tools"
2941
+ detail: needsKeepalive.length > 0 ? needsKeepalive.map((t) => `${t.name} (${t.data === "supabase" ? "supabase" : t.target})`).join(", ") : "no data:supabase / target:oci|docker tools"
2575
2942
  });
2576
2943
  checks.push(versionDriftCheck(root));
2577
2944
  checks.push(submoduleDriftCheck(root));
@@ -2651,7 +3018,7 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}${warned ?
2651
3018
  }
2652
3019
 
2653
3020
  // src/commands/init.ts
2654
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
3021
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync7 } from "fs";
2655
3022
  import { resolve as resolve8 } from "path";
2656
3023
  import { createInterface as createInterface2 } from "readline/promises";
2657
3024
 
@@ -2707,6 +3074,13 @@ ${key} \u2014 ${spec.label}`);
2707
3074
  `${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
2708
3075
  );
2709
3076
  }
3077
+ if (!check.ok) {
3078
+ console.log(
3079
+ ` \xB7 ${key} not pushed (verify failed${check.detail ? `: ${check.detail}` : ""})`
3080
+ );
3081
+ results.push({ envVar: spec.envVar, outcome: "skipped", verify: check });
3082
+ continue;
3083
+ }
2710
3084
  }
2711
3085
  setGitHubSecret(repo, opts.env, key, entered);
2712
3086
  results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
@@ -2807,7 +3181,7 @@ function scaffoldIfAbsent(path, contents, label) {
2807
3181
  return;
2808
3182
  }
2809
3183
  mkdirSync4(resolve8(path, ".."), { recursive: true });
2810
- writeFileSync6(path, contents);
3184
+ writeFileSync7(path, contents);
2811
3185
  console.log(`\u2714 wrote ${label}`);
2812
3186
  }
2813
3187
  var TOKEN_FLAGS = {
@@ -2832,7 +3206,7 @@ async function initCommand(args) {
2832
3206
  if (existsSync10(configPath) && !force) {
2833
3207
  throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
2834
3208
  }
2835
- writeFileSync6(configPath, scaffoldConfig(domain));
3209
+ writeFileSync7(configPath, scaffoldConfig(domain));
2836
3210
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2837
3211
  const repoName = domain.replace(/\./g, "-");
2838
3212
  scaffoldIfAbsent(
@@ -87,11 +87,12 @@ ${tree}`.slice(0, 6e3);
87
87
  async function evalAsserts(page, asserts) {
88
88
  const checks = [];
89
89
  let text = "";
90
+ let textError = "";
90
91
  if (asserts.some((a) => a.textContains)) {
91
92
  try {
92
93
  text = await page.locator("body").innerText({ timeout: 3e3 });
93
- } catch {
94
- text = "";
94
+ } catch (e) {
95
+ textError = msg(e);
95
96
  }
96
97
  }
97
98
  for (const a of asserts) {
@@ -105,7 +106,11 @@ async function evalAsserts(page, asserts) {
105
106
  }
106
107
  if (a.textContains !== void 0) {
107
108
  const ok = text.includes(a.textContains);
108
- checks.push({ name: `text contains "${a.textContains}"`, pass: ok });
109
+ checks.push({
110
+ name: `text contains "${a.textContains}"`,
111
+ pass: ok,
112
+ detail: ok ? void 0 : textError ? `could not read page text: ${textError}` : void 0
113
+ });
109
114
  }
110
115
  if (a.selector !== void 0) {
111
116
  let count = 0;
@@ -123,10 +128,20 @@ async function runScenario(client, page, base, spec, scenario) {
123
128
  await page.goto(base + (scenario.start ?? "/"), { waitUntil: "domcontentloaded" });
124
129
  const messages = [{ role: "user", content: `Task: ${scenario.task}` }];
125
130
  const maxSteps = spec.maxSteps ?? 12;
131
+ const historyTurns = spec.historyWindow ?? 6;
132
+ const maxRepeats = spec.maxRepeats ?? 3;
126
133
  let finish = null;
127
134
  let tokensIn = 0;
128
135
  let tokensOut = 0;
136
+ let budgetExceeded = false;
137
+ let lastFailSig = "";
138
+ let repeats = 0;
139
+ let stuckOn = "";
129
140
  for (let step = 0; step < maxSteps && !finish; step++) {
141
+ if (spec.maxTokens && tokensIn + tokensOut >= spec.maxTokens) {
142
+ budgetExceeded = true;
143
+ break;
144
+ }
130
145
  const resp = await client.messages.create({
131
146
  model: spec.model ?? "claude-sonnet-4-6",
132
147
  max_tokens: 1024,
@@ -149,9 +164,33 @@ async function runScenario(client, page, base, spec, scenario) {
149
164
  results.push({ type: "tool_result", tool_use_id: tu.id, content: out });
150
165
  }
151
166
  messages.push({ role: "user", content: results });
167
+ const sig = toolUses.map((tu) => `${tu.name}:${JSON.stringify(tu.input)}`).join("|");
168
+ const allErrored = results.every((r) => r.content.startsWith("error:"));
169
+ repeats = allErrored && sig === lastFailSig ? repeats + 1 : 0;
170
+ lastFailSig = allErrored ? sig : "";
171
+ if (repeats + 1 >= maxRepeats) {
172
+ stuckOn = toolUses.map((tu) => tu.name).join(", ");
173
+ break;
174
+ }
175
+ const keep = historyTurns * 2;
176
+ if (messages.length > keep + 1) {
177
+ messages.splice(1, messages.length - keep - 1);
178
+ }
152
179
  }
153
180
  const checks = [];
154
- if (!finish) {
181
+ if (budgetExceeded) {
182
+ checks.push({
183
+ name: `${tag} token budget`,
184
+ pass: false,
185
+ detail: `exceeded maxTokens (${spec.maxTokens}) \u2014 ${tokensIn + tokensOut} tokens used`
186
+ });
187
+ } else if (stuckOn) {
188
+ checks.push({
189
+ name: `${tag} progress`,
190
+ pass: false,
191
+ detail: `agent stuck repeating failing action(s): ${stuckOn}`
192
+ });
193
+ } else if (!finish) {
155
194
  checks.push({
156
195
  name: `${tag} completed`,
157
196
  pass: false,
@@ -246,5 +285,6 @@ async function verifyAgentWeb(baseUrl, spec) {
246
285
  }
247
286
 
248
287
  export {
288
+ runScenario,
249
289
  verifyAgentWeb
250
290
  };
@@ -6,13 +6,16 @@ import {
6
6
  // ../packages/verify/src/eval.ts
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
+ var MAX_RESULT_CHARS = 8e3;
9
10
  function resultText(res) {
10
11
  const r = res;
11
- if (r.structuredContent !== void 0) return JSON.stringify(r.structuredContent);
12
- if (Array.isArray(r.content)) {
13
- return r.content.map((c) => c.type === "text" ? c.text ?? "" : JSON.stringify(c)).join("\n");
14
- }
15
- return JSON.stringify(res);
12
+ let text;
13
+ if (r.structuredContent !== void 0) text = JSON.stringify(r.structuredContent);
14
+ else if (Array.isArray(r.content)) {
15
+ text = r.content.map((c) => c.type === "text" ? c.text ?? "" : JSON.stringify(c)).join("\n");
16
+ } else text = JSON.stringify(res);
17
+ return text.length > MAX_RESULT_CHARS ? `${text.slice(0, MAX_RESULT_CHARS)}
18
+ \u2026[truncated ${text.length - MAX_RESULT_CHARS} chars]` : text;
16
19
  }
17
20
  var clamp01 = (n) => {
18
21
  const v = typeof n === "number" ? n : Number(n);
@@ -6,7 +6,7 @@ import {
6
6
  // ../packages/shared/src/schema.ts
7
7
  import { z } from "zod";
8
8
  var LaneEnum = z.enum(["astro", "next", "mcp", "agent"]);
9
- var TargetEnum = z.enum(["workers", "vercel", "oci"]);
9
+ var TargetEnum = z.enum(["workers", "vercel", "oci", "docker"]);
10
10
  var DataEnum = z.enum(["none", "d1", "kv", "supabase", "neon"]);
11
11
  var AuthEnum = z.enum(["none", "bearer", "oauth"]);
12
12
  var AccessEnum = z.enum(["public", "private"]);
@@ -14,7 +14,7 @@ var EnvEnum = z.enum(["preview", "beta", "prod"]);
14
14
  var MATRIX = {
15
15
  astro: { targets: ["workers"], data: ["none", "d1", "kv"] },
16
16
  next: { targets: ["vercel"], data: ["none", "supabase", "neon"] },
17
- mcp: { targets: ["workers", "oci"], data: ["none"] },
17
+ mcp: { targets: ["workers", "oci", "docker"], data: ["none"] },
18
18
  agent: { targets: ["workers"], data: ["none", "kv"] }
19
19
  };
20
20
  function describeMatrix() {
@@ -32,9 +32,9 @@ var ToolSchema = z.object({
32
32
  access: AccessEnum.default("public"),
33
33
  envs: z.array(EnvEnum).nonempty("a tool needs at least one env"),
34
34
  adopted: z.boolean().default(false),
35
- // The port the container listens on (target: oci). The tunnel routes to localhost:<port>;
36
- // defaults to 8000 (the mcp/FastMCP convention). Set it for a lane:docker tool on a different
37
- // port so the oci modules stay generic. Ignored by non-oci targets.
35
+ // The port the container listens on (target: oci | docker). The Cloudflare tunnel routes to
36
+ // localhost:<port>; defaults to 8000 (the mcp/FastMCP convention). Set it for a container tool
37
+ // on a different port so the tunnel/modules stay generic. Ignored by non-container targets.
38
38
  port: z.number().int().positive().optional(),
39
39
  // Directory the tool builds/deploys from. Defaults to tools/<name>; a standalone
40
40
  // (poly-repo) tool sets '.' (the repo root).
@@ -44,7 +44,7 @@ var ToolSchema = z.object({
44
44
  external: z.boolean().default(false),
45
45
  // How `greenlight preview` spins the tool up LOCALLY for the pre-deploy gate. Optional — node
46
46
  // lanes (astro/next/mcp→workers) use the built-in build+serve path. Set it for targets with no
47
- // built-in serve (e.g. oci: a docker command that matches the prod transport). The harness polls
47
+ // built-in serve (e.g. oci/docker: a docker command that matches the prod transport). The harness polls
48
48
  // the local URL (http://localhost:<port><path>), verifies, then runs `teardown`.
49
49
  preview: z.object({
50
50
  command: z.string(),
@@ -69,7 +69,12 @@ var ToolSchema = z.object({
69
69
  // The value is the OWNER tool's name; this tool emits no data module and wires the owner's
70
70
  // connection strings. Cross-tool validity (owner exists, same data, no chains) is checked on
71
71
  // the whole config below.
72
- dataShareWith: z.string().optional()
72
+ dataShareWith: z.string().optional(),
73
+ // Gate prod DB migrations behind a human approval. When true, `adopt` emits a dedicated, gated
74
+ // migrate workflow (run under the `<name>-prod` GitHub Environment → required reviewers pause it)
75
+ // and the prod environment's `prod_reviewers` should be set in infra. Only meaningful for a data
76
+ // tool (supabase/neon); a `doctor` check flags it when set but unwired. Default off.
77
+ requireMigrationApproval: z.boolean().default(false)
73
78
  }).superRefine((tool, ctx) => {
74
79
  const rule = MATRIX[tool.lane];
75
80
  if (!rule.targets.includes(tool.target)) {
@@ -109,8 +114,9 @@ var BlogSchema = z.object({
109
114
  var AlertsSchema = z.object({
110
115
  sink: z.enum(["github-issue", "email"])
111
116
  });
117
+ var DOMAIN_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
112
118
  var ConfigSchema = z.object({
113
- domain: z.string().min(1, "domain is required"),
119
+ domain: z.string().min(1, "domain is required").regex(DOMAIN_RE, 'domain must be a valid hostname, e.g. "example.com"'),
114
120
  alerts: AlertsSchema,
115
121
  // Optional: a tool-only repo (a poly-repo consumer) has no blog.
116
122
  blog: BlogSchema.optional(),
@@ -155,7 +161,14 @@ function defineConfig(config) {
155
161
  import { createJiti } from "jiti";
156
162
  async function loadConfig(path) {
157
163
  const jiti = createJiti(import.meta.url);
158
- const mod = await jiti.import(path);
164
+ let mod;
165
+ try {
166
+ mod = await jiti.import(path);
167
+ } catch (e) {
168
+ throw new Error(
169
+ `Could not load Greenlight manifest at ${path}: ${e instanceof Error ? e.message : String(e)}`
170
+ );
171
+ }
159
172
  const raw = "default" in mod ? mod.default : mod;
160
173
  const result = ConfigSchema.safeParse(raw);
161
174
  if (!result.success) {
@@ -283,9 +296,27 @@ import { setTimeout as sleep } from "timers/promises";
283
296
  var trimSlash = (s) => s.replace(/\/+$/, "");
284
297
  var DEFAULT_TIMEOUT_MS = 1e4;
285
298
  var DEFAULT_MAX_LINKS = 50;
299
+ var MAX_BODY_CHARS = 2e6;
286
300
  function timedFetch(url, timeoutMs, init) {
287
301
  return fetch(url, { redirect: "manual", ...init, signal: AbortSignal.timeout(timeoutMs) });
288
302
  }
303
+ async function boundedText(res, max = MAX_BODY_CHARS) {
304
+ if (!res.body) return (await res.text()).slice(0, max);
305
+ const reader = res.body.getReader();
306
+ const decoder = new TextDecoder();
307
+ let text = "";
308
+ try {
309
+ while (text.length < max) {
310
+ const { done, value } = await reader.read();
311
+ if (done) break;
312
+ text += decoder.decode(value, { stream: true });
313
+ }
314
+ } finally {
315
+ await reader.cancel().catch(() => {
316
+ });
317
+ }
318
+ return text.slice(0, max);
319
+ }
289
320
  async function checkRoute(base, c, timeoutMs) {
290
321
  const name = `GET ${c.path}`;
291
322
  try {
@@ -295,7 +326,7 @@ async function checkRoute(base, c, timeoutMs) {
295
326
  reasons.push(`status ${res.status} != ${c.status}`);
296
327
  }
297
328
  if (c.contains !== void 0) {
298
- const body = await res.text();
329
+ const body = await boundedText(res);
299
330
  if (!body.includes(c.contains)) reasons.push(`body missing "${c.contains}"`);
300
331
  }
301
332
  if (c.header) {
@@ -315,7 +346,7 @@ async function checkXml(base, candidates, label, marker, timeoutMs) {
315
346
  try {
316
347
  const res = await timedFetch(base + path, timeoutMs);
317
348
  if (res.status === 200) {
318
- const body = await res.text();
349
+ const body = await boundedText(res);
319
350
  const ok = marker.test(body);
320
351
  return {
321
352
  name: `${label} (${path})`,
@@ -331,7 +362,7 @@ async function checkXml(base, candidates, label, marker, timeoutMs) {
331
362
  async function checkInternalLinks(base, timeoutMs, max = DEFAULT_MAX_LINKS) {
332
363
  try {
333
364
  const res = await timedFetch(`${base}/`, timeoutMs);
334
- const html = await res.text();
365
+ const html = await boundedText(res);
335
366
  const hrefs = /* @__PURE__ */ new Set();
336
367
  let capped = false;
337
368
  for (const m of html.matchAll(/href="(\/[^"#?]*)"/g)) {
@@ -491,7 +522,7 @@ async function verify(baseUrl, spec, opts) {
491
522
  case "api":
492
523
  return verifyApi(baseUrl, spec);
493
524
  case "mcp": {
494
- const { verifyMcp: verifyMcp2 } = await import("./mcp-FFLOX4YP.js");
525
+ const { verifyMcp: verifyMcp2 } = await import("./mcp-KHLYS3RL.js");
495
526
  return verifyMcp2(baseUrl, spec);
496
527
  }
497
528
  case "playwright": {
@@ -503,11 +534,11 @@ async function verify(baseUrl, spec, opts) {
503
534
  return verifyTest2(spec, opts?.toolDir ?? process.cwd());
504
535
  }
505
536
  case "agent-web": {
506
- const { verifyAgentWeb: verifyAgentWeb2 } = await import("./agent-web-BG5ZIVAB.js");
537
+ const { verifyAgentWeb: verifyAgentWeb2 } = await import("./agent-web-JVJFUAIZ.js");
507
538
  return verifyAgentWeb2(baseUrl, spec);
508
539
  }
509
540
  case "eval": {
510
- const { verifyEval: verifyEval2 } = await import("./eval-YZXJSUKH.js");
541
+ const { verifyEval: verifyEval2 } = await import("./eval-45HUMZ6V.js");
511
542
  return verifyEval2(baseUrl, spec);
512
543
  }
513
544
  }
@@ -6,22 +6,34 @@ import {
6
6
  // ../packages/verify/src/mcp.ts
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
+ var DEFAULT_TIMEOUT_MS = 1e4;
10
+ function withTimeout(p, timeoutMs, label) {
11
+ return Promise.race([
12
+ p,
13
+ new Promise(
14
+ (_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
15
+ )
16
+ ]);
17
+ }
9
18
  async function verifyMcp(baseUrl, spec) {
10
19
  const checks = [];
20
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
11
21
  const client = new Client({ name: "greenlight-verify", version: "0.0.0" });
12
22
  const transport = new StreamableHTTPClientTransport(
13
23
  new URL(baseUrl),
14
24
  spec.headers ? { requestInit: { headers: spec.headers } } : void 0
15
25
  );
16
26
  try {
17
- await client.connect(transport);
27
+ await withTimeout(client.connect(transport), timeoutMs, "initialize");
18
28
  checks.push({ name: "initialize handshake", pass: true });
19
29
  } catch (e) {
20
30
  checks.push({ name: "initialize handshake", pass: false, detail: msg(e) });
31
+ await client.close().catch(() => {
32
+ });
21
33
  return report("mcp", baseUrl, checks);
22
34
  }
23
35
  try {
24
- const { tools } = await client.listTools();
36
+ const { tools } = await withTimeout(client.listTools(), timeoutMs, "tools/list");
25
37
  const names = tools.map((t) => t.name);
26
38
  checks.push({ name: `tools/list responded (${names.length} tools)`, pass: true });
27
39
  for (const t of spec.expectTools) {
@@ -52,10 +64,11 @@ async function verifyMcp(baseUrl, spec) {
52
64
  if (spec.call) {
53
65
  const label = `tools/call ${spec.call.name}`;
54
66
  try {
55
- const res = await client.callTool({
56
- name: spec.call.name,
57
- arguments: spec.call.args ?? {}
58
- });
67
+ const res = await withTimeout(
68
+ client.callTool({ name: spec.call.name, arguments: spec.call.args ?? {} }),
69
+ timeoutMs,
70
+ label
71
+ );
59
72
  const reasons = [];
60
73
  if (res.isError) reasons.push("result.isError = true");
61
74
  for (const k of spec.call.expectKeys ?? []) {
@@ -73,13 +86,14 @@ async function verifyMcp(baseUrl, spec) {
73
86
  }
74
87
  }
75
88
  await client.close();
76
- if (spec.requireAuthRejection) checks.push(await checkAuthRejection(baseUrl));
89
+ if (spec.requireAuthRejection) checks.push(await checkAuthRejection(baseUrl, timeoutMs));
77
90
  return report("mcp", baseUrl, checks);
78
91
  }
79
- async function checkAuthRejection(baseUrl) {
92
+ async function checkAuthRejection(baseUrl, timeoutMs) {
80
93
  try {
81
94
  const res = await fetch(baseUrl, {
82
95
  method: "POST",
96
+ signal: AbortSignal.timeout(timeoutMs),
83
97
  headers: {
84
98
  "content-type": "application/json",
85
99
  accept: "application/json, text/event-stream"
@@ -107,5 +121,6 @@ async function checkAuthRejection(baseUrl) {
107
121
  }
108
122
 
109
123
  export {
124
+ withTimeout,
110
125
  verifyMcp
111
126
  };
@@ -2,7 +2,7 @@ import {
2
2
  clamp01,
3
3
  llmJudge,
4
4
  verifyEval
5
- } from "./chunk-3A6F2JNP.js";
5
+ } from "./chunk-C6NJHCRI.js";
6
6
  import "./chunk-QFKE5JKC.js";
7
7
  export {
8
8
  clamp01,
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { GreenlightConfig, defineConfig, loadConfig } from '@rtrentjones/greenlight-shared';
1
+ export { GreenlightConfig, ResolveUrlOptions, defineConfig, loadConfig, resolveUrl } from '@rtrentjones/greenlight-shared';
2
2
  export { VerifySpec, defineVerify } from '@rtrentjones/greenlight-verify';
package/dist/index.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import {
2
2
  defineConfig,
3
3
  defineVerify,
4
- loadConfig
5
- } from "./chunk-FZH2YQPJ.js";
6
- import "./chunk-HX7VA25D.js";
4
+ loadConfig,
5
+ resolveUrl
6
+ } from "./chunk-NFNVF4HV.js";
7
+ import "./chunk-UMY5ZDOQ.js";
7
8
  import "./chunk-N3IKUCSF.js";
8
9
  import "./chunk-KP3Y6WRU.js";
9
- import "./chunk-IYEIZYI5.js";
10
- import "./chunk-3A6F2JNP.js";
10
+ import "./chunk-2LAYOVFP.js";
11
+ import "./chunk-C6NJHCRI.js";
11
12
  import "./chunk-QFKE5JKC.js";
12
13
  export {
13
14
  defineConfig,
14
15
  defineVerify,
15
- loadConfig
16
+ loadConfig,
17
+ resolveUrl
16
18
  };
@@ -0,0 +1,9 @@
1
+ import {
2
+ verifyMcp,
3
+ withTimeout
4
+ } from "./chunk-UMY5ZDOQ.js";
5
+ import "./chunk-QFKE5JKC.js";
6
+ export {
7
+ verifyMcp,
8
+ withTimeout
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,10 @@
31
31
  "@anthropic-ai/sdk": "^0.69.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@rtrentjones/greenlight-adapters": "0.6.0",
35
- "@rtrentjones/greenlight-shared": "0.6.0",
36
- "@rtrentjones/greenlight-verify": "0.6.0",
37
- "@rtrentjones/greenlight-loop": "0.6.0"
34
+ "@rtrentjones/greenlight-loop": "0.7.0",
35
+ "@rtrentjones/greenlight-shared": "0.7.0",
36
+ "@rtrentjones/greenlight-adapters": "0.7.0",
37
+ "@rtrentjones/greenlight-verify": "0.7.0"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",
@@ -1,7 +0,0 @@
1
- import {
2
- verifyMcp
3
- } from "./chunk-HX7VA25D.js";
4
- import "./chunk-QFKE5JKC.js";
5
- export {
6
- verifyMcp
7
- };