@rtrentjones/greenlight 0.6.1 → 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-MDG3MT34.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.1";
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
  `;
@@ -1745,7 +1849,19 @@ function ociDeployAndVerifySteps(name) {
1745
1849
  # posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
1746
1850
  run: pnpm exec greenlight verify ${name} --env prod`;
1747
1851
  }
1748
- 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)) {
1749
1865
  return `name: greenlight-deploy-${name}
1750
1866
 
1751
1867
  # Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
@@ -1769,7 +1885,7 @@ jobs:
1769
1885
  - uses: actions/checkout@v4
1770
1886
  - uses: jdx/mise-action@v2
1771
1887
  - run: pnpm install --frozen-lockfile
1772
- ${ociDeployAndVerifySteps(name)}
1888
+ ${deploySteps}
1773
1889
  - name: Report status back to ${toolRepo}
1774
1890
  if: \${{ always() && github.event.client_payload.sha != '' }}
1775
1891
  env:
@@ -1842,6 +1958,83 @@ ${ociDeployAndVerifySteps(name)}
1842
1958
  --label keepalive || true
1843
1959
  `;
1844
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
+ }
1845
2038
  function verifyWorkflowYml(name) {
1846
2039
  return `name: greenlight-verify
1847
2040
 
@@ -1932,7 +2125,7 @@ async function adoptCommand(args) {
1932
2125
  const name = args[0];
1933
2126
  if (!name || name.startsWith("-")) {
1934
2127
  throw new Error(
1935
- "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)."
1936
2129
  );
1937
2130
  }
1938
2131
  const repoArg = flag3(args, "--repo");
@@ -1943,6 +2136,7 @@ async function adoptCommand(args) {
1943
2136
  const data = flag3(args, "--data") ?? "none";
1944
2137
  const auth = flag3(args, "--auth") ?? "none";
1945
2138
  const envs = flag3(args, "--envs")?.split(",") ?? ["beta", "prod"];
2139
+ const requireMigrationApproval = args.includes("--require-migration-approval");
1946
2140
  const { path: regPath, config: reg } = await loadManifest();
1947
2141
  if (regPath.endsWith(".example.ts")) {
1948
2142
  throw new Error(
@@ -1953,12 +2147,36 @@ async function adoptCommand(args) {
1953
2147
  throw new Error('"blog" is the apex site, not an adopted tool');
1954
2148
  }
1955
2149
  const domain = flag3(args, "--domain") ?? reg.domain;
1956
- 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
+ };
1957
2163
  if (args.includes("--standalone")) return adoptStandalone(ctx);
1958
2164
  return adoptWrapper(ctx);
1959
2165
  }
1960
2166
  async function adoptWrapper(ctx) {
1961
- 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;
1962
2180
  const cwd = process.cwd();
1963
2181
  const toolRel = `tools/${name}`;
1964
2182
  const dest = resolve6(cwd, toolRel);
@@ -1988,17 +2206,18 @@ async function adoptWrapper(ctx) {
1988
2206
  dir: toolRel,
1989
2207
  external: true,
1990
2208
  adopted: true,
1991
- // oci has no built-in local serve — scaffold a `preview` descriptor so the uniform local gate
1992
- // (`greenlight preview <name>`) works. Default: a docker `preview` profile matching the prod
1993
- // transport (the tool adds that profile to its compose). Edit the command/port/path to fit.
1994
- ...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" ? {
1995
2213
  preview: {
1996
2214
  command: "docker compose --profile preview up",
1997
2215
  teardown: "docker compose --profile preview down -v",
1998
2216
  port: 8e3,
1999
2217
  path: lane === "mcp" ? "/mcp" : ""
2000
2218
  }
2001
- } : {}
2219
+ } : {},
2220
+ ...requireMigrationApproval ? { requireMigrationApproval: true } : {}
2002
2221
  });
2003
2222
  writeFileSync3(regPath, serializeConfig(nextReg));
2004
2223
  console.log(
@@ -2023,16 +2242,17 @@ async function adoptWrapper(ctx) {
2023
2242
  }
2024
2243
  materializeAgentKit(dest, { lane, target, data });
2025
2244
  addGreenlightScript(dest);
2026
- if (target === "oci") {
2245
+ if (target === "oci" || target === "docker") {
2027
2246
  const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
2247
+ const deploySteps = target === "docker" ? dockerDeployAndVerifySteps(name) : ociDeployAndVerifySteps(name);
2028
2248
  writeIfAbsent(
2029
2249
  join2(cwd, `.github/workflows/greenlight-deploy-${name}.yml`),
2030
- deployListenerYml(name, slug),
2250
+ deployListenerYml(name, slug, deploySteps),
2031
2251
  `.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
2032
2252
  );
2033
2253
  writeIfAbsent(
2034
2254
  join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
2035
- remediateYml(name),
2255
+ target === "docker" ? dockerRemediateYml(name) : remediateYml(name),
2036
2256
  `.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
2037
2257
  );
2038
2258
  writeIfAbsent(
@@ -2053,6 +2273,13 @@ async function adoptWrapper(ctx) {
2053
2273
  `${toolRel}/.github/workflows/greenlight-verify.yml (verify on Vercel deployment_status)`
2054
2274
  );
2055
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
+ }
2056
2283
  console.log(`
2057
2284
  Next:
2058
2285
  (in the tool repo) commit the Greenlight kit + build workflow so they travel with the submodule:
@@ -2062,14 +2289,30 @@ Next:
2062
2289
  git commit && git push # CI (infra.yml) applies. Tool's CI builds; wrapper deploys.${target === "oci" ? `
2063
2290
  Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # TF_VAR_OCI_* + GREENLIGHT_STATUS_TOKEN
2064
2291
  greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
2065
- 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" ? `
2066
2297
  Deploy is Vercel's git integration (no wrapper deploy). The tool's greenlight-verify.yml verifies
2067
2298
  each deployment (deployment_status). Optional secrets on ${slug}:
2068
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
2069
2300
  \xB7 ANTHROPIC_API_KEY \u2192 enables the agent-web scenarios in verify/${name}.config.ts (absent \u2192 api gate alone)` : ""}`);
2070
2301
  }
2071
2302
  async function adoptStandalone(ctx) {
2072
- 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;
2073
2316
  const repo = resolve6(process.cwd(), repoArg);
2074
2317
  if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
2075
2318
  const regVendor = resolve6(process.cwd(), "vendor");
@@ -2081,7 +2324,17 @@ async function adoptStandalone(ctx) {
2081
2324
  }
2082
2325
  console.log(`adopting "${name}" (${lane}/${target}) into ${repo} (standalone)
2083
2326
  `);
2084
- 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
+ };
2085
2338
  const toolConfig = addTool({ domain, alerts: { sink: "github-issue" }, tools: [] }, toolEntry);
2086
2339
  writeIfAbsent(
2087
2340
  join2(repo, "greenlight.config.ts"),
@@ -2119,6 +2372,13 @@ async function adoptStandalone(ctx) {
2119
2372
  ".github/workflows/greenlight-promote.yml"
2120
2373
  );
2121
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
+ }
2122
2382
  materializeAgentKit(repo, { lane, target, data });
2123
2383
  writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
2124
2384
  writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
@@ -2130,7 +2390,8 @@ async function adoptStandalone(ctx) {
2130
2390
  auth,
2131
2391
  envs,
2132
2392
  external: true,
2133
- adopted: true
2393
+ adopted: true,
2394
+ ...requireMigrationApproval ? { requireMigrationApproval: true } : {}
2134
2395
  });
2135
2396
  writeFileSync3(regPath, serializeConfig(nextReg));
2136
2397
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
@@ -2268,6 +2529,8 @@ async function configCommand() {
2268
2529
 
2269
2530
  // ../packages/adapters/src/index.ts
2270
2531
  import { execFileSync as execFileSync3 } from "child_process";
2532
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync as writeFileSync6 } from "fs";
2533
+ import { tmpdir } from "os";
2271
2534
  import { join as join4 } from "path";
2272
2535
  function run(cmd, args, cwd, extraEnv) {
2273
2536
  execFileSync3(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...extraEnv } });
@@ -2329,6 +2592,63 @@ function ociAdapter(ctx) {
2329
2592
  }
2330
2593
  };
2331
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
+ }
2332
2652
  function vercelSkeletonAdapter(ctx) {
2333
2653
  const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
2334
2654
  const notWired = () => {
@@ -2350,6 +2670,8 @@ function createAdapter(target, ctx) {
2350
2670
  return workersAdapter(ctx);
2351
2671
  case "oci":
2352
2672
  return ociAdapter(ctx);
2673
+ case "docker":
2674
+ return dockerAdapter(ctx);
2353
2675
  case "vercel":
2354
2676
  return vercelSkeletonAdapter(ctx);
2355
2677
  }
@@ -2371,7 +2693,7 @@ async function deployCommand(args) {
2371
2693
  }
2372
2694
  const { config } = await loadManifest();
2373
2695
  const entry = resolveEntry(config, name);
2374
- if (entry.external && entry.target !== "oci") {
2696
+ if (entry.external && entry.target !== "oci" && entry.target !== "docker") {
2375
2697
  throw new Error(`"${name}" is external (registry pointer) \u2014 deploy it from its own repo`);
2376
2698
  }
2377
2699
  const adapter = createAdapter(entry.target, { domain: config.domain, name: entry.name });
@@ -2532,6 +2854,18 @@ function conformanceChecks(t, root) {
2532
2854
  });
2533
2855
  }
2534
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
+ }
2535
2869
  return out;
2536
2870
  }
2537
2871
  function versionDriftCheck(root) {
@@ -2598,11 +2932,13 @@ function runDoctor(config, root) {
2598
2932
  }
2599
2933
  checks.push(...conformanceChecks(t, root));
2600
2934
  }
2601
- 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
+ );
2602
2938
  checks.push({
2603
2939
  name: "keepalive coverage",
2604
2940
  status: needsKeepalive.length > 0 ? "ok" : "skip",
2605
- 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"
2606
2942
  });
2607
2943
  checks.push(versionDriftCheck(root));
2608
2944
  checks.push(submoduleDriftCheck(root));
@@ -2682,7 +3018,7 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}${warned ?
2682
3018
  }
2683
3019
 
2684
3020
  // src/commands/init.ts
2685
- 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";
2686
3022
  import { resolve as resolve8 } from "path";
2687
3023
  import { createInterface as createInterface2 } from "readline/promises";
2688
3024
 
@@ -2738,6 +3074,13 @@ ${key} \u2014 ${spec.label}`);
2738
3074
  `${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
2739
3075
  );
2740
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
+ }
2741
3084
  }
2742
3085
  setGitHubSecret(repo, opts.env, key, entered);
2743
3086
  results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
@@ -2838,7 +3181,7 @@ function scaffoldIfAbsent(path, contents, label) {
2838
3181
  return;
2839
3182
  }
2840
3183
  mkdirSync4(resolve8(path, ".."), { recursive: true });
2841
- writeFileSync6(path, contents);
3184
+ writeFileSync7(path, contents);
2842
3185
  console.log(`\u2714 wrote ${label}`);
2843
3186
  }
2844
3187
  var TOKEN_FLAGS = {
@@ -2863,7 +3206,7 @@ async function initCommand(args) {
2863
3206
  if (existsSync10(configPath) && !force) {
2864
3207
  throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
2865
3208
  }
2866
- writeFileSync6(configPath, scaffoldConfig(domain));
3209
+ writeFileSync7(configPath, scaffoldConfig(domain));
2867
3210
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2868
3211
  const repoName = domain.replace(/\./g, "-");
2869
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.js CHANGED
@@ -3,12 +3,12 @@ import {
3
3
  defineVerify,
4
4
  loadConfig,
5
5
  resolveUrl
6
- } from "./chunk-MDG3MT34.js";
7
- import "./chunk-HX7VA25D.js";
6
+ } from "./chunk-NFNVF4HV.js";
7
+ import "./chunk-UMY5ZDOQ.js";
8
8
  import "./chunk-N3IKUCSF.js";
9
9
  import "./chunk-KP3Y6WRU.js";
10
- import "./chunk-IYEIZYI5.js";
11
- import "./chunk-3A6F2JNP.js";
10
+ import "./chunk-2LAYOVFP.js";
11
+ import "./chunk-C6NJHCRI.js";
12
12
  import "./chunk-QFKE5JKC.js";
13
13
  export {
14
14
  defineConfig,
@@ -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.1",
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.1",
35
- "@rtrentjones/greenlight-loop": "0.6.1",
36
- "@rtrentjones/greenlight-shared": "0.6.1",
37
- "@rtrentjones/greenlight-verify": "0.6.1"
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
- };