@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.
- package/assets/skills/provider-docker/SKILL.md +68 -0
- package/dist/{agent-web-BG5ZIVAB.js → agent-web-JVJFUAIZ.js} +3 -1
- package/dist/bin.js +383 -40
- package/dist/{chunk-IYEIZYI5.js → chunk-2LAYOVFP.js} +44 -4
- package/dist/{chunk-3A6F2JNP.js → chunk-C6NJHCRI.js} +8 -5
- package/dist/{chunk-MDG3MT34.js → chunk-NFNVF4HV.js} +46 -15
- package/dist/{chunk-HX7VA25D.js → chunk-UMY5ZDOQ.js} +23 -8
- package/dist/{eval-YZXJSUKH.js → eval-45HUMZ6V.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/mcp-KHLYS3RL.js +9 -0
- package/package.json +5 -5
- package/dist/mcp-FFLOX4YP.js +0 -7
|
@@ -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.
|
package/dist/bin.js
CHANGED
|
@@ -9,12 +9,12 @@ import {
|
|
|
9
9
|
scanSqlFiles,
|
|
10
10
|
toExportResult,
|
|
11
11
|
verifyAll
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
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-
|
|
17
|
-
import "./chunk-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}` :
|
|
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
|
|
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
|
-
${
|
|
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 = {
|
|
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 {
|
|
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
|
|
1992
|
-
// (`greenlight preview <name>`) works. Default: a docker `preview`
|
|
1993
|
-
// transport (the tool adds that profile to its compose). Edit
|
|
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 === "
|
|
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 {
|
|
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 = {
|
|
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(
|
|
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" :
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
36
|
-
// defaults to 8000 (the mcp/FastMCP convention). Set it for a
|
|
37
|
-
// port so the
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
56
|
-
name: spec.call.name,
|
|
57
|
-
|
|
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
|
};
|
package/dist/index.js
CHANGED
|
@@ -3,12 +3,12 @@ import {
|
|
|
3
3
|
defineVerify,
|
|
4
4
|
loadConfig,
|
|
5
5
|
resolveUrl
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
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-
|
|
11
|
-
import "./chunk-
|
|
10
|
+
import "./chunk-2LAYOVFP.js";
|
|
11
|
+
import "./chunk-C6NJHCRI.js";
|
|
12
12
|
import "./chunk-QFKE5JKC.js";
|
|
13
13
|
export {
|
|
14
14
|
defineConfig,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "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-
|
|
35
|
-
"@rtrentjones/greenlight-
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-verify": "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",
|