@rtrentjones/greenlight 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +414 -40
- package/dist/{chunk-IYEIZYI5.js → chunk-2LAYOVFP.js} +44 -4
- package/dist/{chunk-3A6F2JNP.js → chunk-C6NJHCRI.js} +8 -5
- package/dist/{chunk-FZH2YQPJ.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.d.ts +1 -1
- package/dist/index.js +8 -6
- 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
|
`;
|
|
@@ -1121,6 +1225,14 @@ function setGitHubSecret(repo, env, key, value) {
|
|
|
1121
1225
|
throw new Error(`failed to set ${key}${detail ? `: ${detail}` : " (check `gh auth status`)"}`);
|
|
1122
1226
|
}
|
|
1123
1227
|
}
|
|
1228
|
+
function appSecretsToGather(entry, packs) {
|
|
1229
|
+
const packKeys = new Set(
|
|
1230
|
+
packs.flatMap(
|
|
1231
|
+
(p) => p.tokens.map((t) => secretKeyFor(t, entry.name ?? "", entry.tokenOverrides))
|
|
1232
|
+
)
|
|
1233
|
+
);
|
|
1234
|
+
return (entry.tokens ?? []).filter((k) => !packKeys.has(k));
|
|
1235
|
+
}
|
|
1124
1236
|
async function gatherSecrets(name, repo, env, prefill) {
|
|
1125
1237
|
const { config } = await loadManifest();
|
|
1126
1238
|
const entry = resolveEntry(config, name);
|
|
@@ -1179,6 +1291,29 @@ async function gatherSecrets(name, repo, env, prefill) {
|
|
|
1179
1291
|
pushed++;
|
|
1180
1292
|
}
|
|
1181
1293
|
}
|
|
1294
|
+
const appSecrets = appSecretsToGather(entry, packs);
|
|
1295
|
+
if (appSecrets.length) {
|
|
1296
|
+
console.log(`\u2500\u2500 ${name} (app secrets)`);
|
|
1297
|
+
for (const key of appSecrets) {
|
|
1298
|
+
const pre = prefill?.get(key);
|
|
1299
|
+
if (pre) {
|
|
1300
|
+
setGitHubSecret(repo, env, key, pre);
|
|
1301
|
+
console.log(` \u2714 ${existing?.has(key) ? "overrode" : "pushed"} ${key} \u2190 prefill`);
|
|
1302
|
+
pushed++;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
const state = existing ? existing.has(key) ? " [already set]" : " [not set]" : "";
|
|
1306
|
+
const value = await prompt.ask(` ${key}${state}
|
|
1307
|
+
value: `);
|
|
1308
|
+
if (!value) {
|
|
1309
|
+
console.log(existing?.has(key) ? " \xB7 kept existing" : " \xB7 skipped");
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
setGitHubSecret(repo, env, key, value);
|
|
1313
|
+
console.log(` \u2714 ${existing?.has(key) ? "overrode" : "pushed"} ${key} \u2192 ${repo}`);
|
|
1314
|
+
pushed++;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1182
1317
|
} finally {
|
|
1183
1318
|
prompt.close();
|
|
1184
1319
|
}
|
|
@@ -1714,7 +1849,19 @@ function ociDeployAndVerifySteps(name) {
|
|
|
1714
1849
|
# posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
|
|
1715
1850
|
run: pnpm exec greenlight verify ${name} --env prod`;
|
|
1716
1851
|
}
|
|
1717
|
-
function
|
|
1852
|
+
function dockerDeployAndVerifySteps(name) {
|
|
1853
|
+
const SUF = name.toUpperCase().replace(/-/g, "_");
|
|
1854
|
+
return ` - name: Deploy (SSH \`docker compose pull && up -d\` on the host)
|
|
1855
|
+
env:
|
|
1856
|
+
DOCKER_SSH_HOST: \${{ secrets.DOCKER_SSH_HOST_${SUF} }}
|
|
1857
|
+
DOCKER_SSH_USER: \${{ secrets.DOCKER_SSH_USER_${SUF} }}
|
|
1858
|
+
DOCKER_SSH_KEY: \${{ secrets.DOCKER_SSH_KEY_${SUF} }}
|
|
1859
|
+
DOCKER_SSH_PORT: \${{ secrets.DOCKER_SSH_PORT_${SUF} }}
|
|
1860
|
+
run: pnpm exec greenlight deploy ${name} --env prod
|
|
1861
|
+
- name: Verify prod (gate the signal on real health, not just the restart)
|
|
1862
|
+
run: pnpm exec greenlight verify ${name} --env prod`;
|
|
1863
|
+
}
|
|
1864
|
+
function deployListenerYml(name, toolRepo, deploySteps = ociDeployAndVerifySteps(name)) {
|
|
1718
1865
|
return `name: greenlight-deploy-${name}
|
|
1719
1866
|
|
|
1720
1867
|
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
@@ -1738,7 +1885,7 @@ jobs:
|
|
|
1738
1885
|
- uses: actions/checkout@v4
|
|
1739
1886
|
- uses: jdx/mise-action@v2
|
|
1740
1887
|
- run: pnpm install --frozen-lockfile
|
|
1741
|
-
${
|
|
1888
|
+
${deploySteps}
|
|
1742
1889
|
- name: Report status back to ${toolRepo}
|
|
1743
1890
|
if: \${{ always() && github.event.client_payload.sha != '' }}
|
|
1744
1891
|
env:
|
|
@@ -1811,6 +1958,83 @@ ${ociDeployAndVerifySteps(name)}
|
|
|
1811
1958
|
--label keepalive || true
|
|
1812
1959
|
`;
|
|
1813
1960
|
}
|
|
1961
|
+
function migrateWorkflowYml(name) {
|
|
1962
|
+
const SUF = name.toUpperCase().replace(/-/g, "_");
|
|
1963
|
+
return `name: greenlight-migrate-${name}
|
|
1964
|
+
|
|
1965
|
+
# Manual-approval prod DB migration gate. Set the required reviewers on the \`${name}-prod\`
|
|
1966
|
+
# environment (the tool module's \`prod_reviewers\`, or repo Settings \u2192 Environments) \u2014 GitHub pauses
|
|
1967
|
+
# the job below until one approves. Move migrate OUT of the app/platform build into this gated job.
|
|
1968
|
+
on:
|
|
1969
|
+
workflow_dispatch:
|
|
1970
|
+
# Tip: gate the prod deploy on this \u2014 call it first, or make the deploy \`needs:\` a green run here.
|
|
1971
|
+
|
|
1972
|
+
permissions:
|
|
1973
|
+
contents: read
|
|
1974
|
+
|
|
1975
|
+
concurrency:
|
|
1976
|
+
group: migrate-${name}
|
|
1977
|
+
cancel-in-progress: false
|
|
1978
|
+
|
|
1979
|
+
jobs:
|
|
1980
|
+
migrate:
|
|
1981
|
+
runs-on: ubuntu-latest
|
|
1982
|
+
# Required-reviewer gate: GitHub pauses here until a reviewer approves the ${name}-prod environment.
|
|
1983
|
+
environment: ${name}-prod
|
|
1984
|
+
steps:
|
|
1985
|
+
- uses: actions/checkout@v4
|
|
1986
|
+
- uses: jdx/mise-action@v2
|
|
1987
|
+
- run: pnpm install --frozen-lockfile
|
|
1988
|
+
- name: Scan migrations (block destructive SQL)
|
|
1989
|
+
run: pnpm exec greenlight migrations scan --strict
|
|
1990
|
+
- name: Apply prod migrations
|
|
1991
|
+
env:
|
|
1992
|
+
# Prod DIRECT (non-pooled) connection \u2014 set as a secret. Neon/Supabase expose a DIRECT_URL.
|
|
1993
|
+
DIRECT_URL: \${{ secrets.DIRECT_URL_${SUF} }}
|
|
1994
|
+
# TODO: replace with your migrate command against $DIRECT_URL, e.g.
|
|
1995
|
+
# pnpm drizzle-kit migrate | pnpm prisma migrate deploy
|
|
1996
|
+
run: |
|
|
1997
|
+
echo "::error::set the migrate command in greenlight-migrate-${name}.yml (drizzle-kit migrate / prisma migrate deploy)"
|
|
1998
|
+
exit 1
|
|
1999
|
+
`;
|
|
2000
|
+
}
|
|
2001
|
+
function dockerRemediateYml(name) {
|
|
2002
|
+
return `name: greenlight-remediate-${name}
|
|
2003
|
+
|
|
2004
|
+
# Auto-heal: the keepalive Worker dispatches remediate-${name} when ${name} (docker) is unreachable.
|
|
2005
|
+
on:
|
|
2006
|
+
repository_dispatch:
|
|
2007
|
+
types: [remediate-${name}]
|
|
2008
|
+
workflow_dispatch:
|
|
2009
|
+
|
|
2010
|
+
permissions:
|
|
2011
|
+
contents: read
|
|
2012
|
+
issues: write
|
|
2013
|
+
|
|
2014
|
+
# Same group as greenlight-deploy-${name}: a self-heal never overlaps a deploy or another heal.
|
|
2015
|
+
concurrency:
|
|
2016
|
+
group: deploy-${name}
|
|
2017
|
+
cancel-in-progress: false
|
|
2018
|
+
|
|
2019
|
+
jobs:
|
|
2020
|
+
remediate:
|
|
2021
|
+
runs-on: ubuntu-latest
|
|
2022
|
+
steps:
|
|
2023
|
+
- uses: actions/checkout@v4
|
|
2024
|
+
- uses: jdx/mise-action@v2
|
|
2025
|
+
- run: pnpm install --frozen-lockfile
|
|
2026
|
+
${dockerDeployAndVerifySteps(name)}
|
|
2027
|
+
- name: Escalate if the self-heal failed
|
|
2028
|
+
if: \${{ failure() }}
|
|
2029
|
+
env:
|
|
2030
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2031
|
+
run: |
|
|
2032
|
+
gh issue create --repo \${{ github.repository }} \\
|
|
2033
|
+
--title "remediate-${name}: self-heal FAILED" \\
|
|
2034
|
+
--body "Automatic remediation for ${name} (reason: \${{ github.event.client_payload.reason }}) did not bring prod back. Manual attention needed." \\
|
|
2035
|
+
--label keepalive || true
|
|
2036
|
+
`;
|
|
2037
|
+
}
|
|
1814
2038
|
function verifyWorkflowYml(name) {
|
|
1815
2039
|
return `name: greenlight-verify
|
|
1816
2040
|
|
|
@@ -1901,7 +2125,7 @@ async function adoptCommand(args) {
|
|
|
1901
2125
|
const name = args[0];
|
|
1902
2126
|
if (!name || name.startsWith("-")) {
|
|
1903
2127
|
throw new Error(
|
|
1904
|
-
"usage: greenlight adopt <name> --repo <url|path> --lane <l> --target <t> [--data --auth --envs] [--standalone]\n default: wrap <repo> as a tools/<name> submodule + edit infra in this wrapper + push the loop kit into the tool repo.\n --standalone: scaffold a full self-contained consumer into the tool repo (it owns its whole stack)."
|
|
2128
|
+
"usage: greenlight adopt <name> --repo <url|path> --lane <l> --target <t> [--data --auth --envs] [--require-migration-approval] [--standalone]\n default: wrap <repo> as a tools/<name> submodule + edit infra in this wrapper + push the loop kit into the tool repo.\n --standalone: scaffold a full self-contained consumer into the tool repo (it owns its whole stack)."
|
|
1905
2129
|
);
|
|
1906
2130
|
}
|
|
1907
2131
|
const repoArg = flag3(args, "--repo");
|
|
@@ -1912,6 +2136,7 @@ async function adoptCommand(args) {
|
|
|
1912
2136
|
const data = flag3(args, "--data") ?? "none";
|
|
1913
2137
|
const auth = flag3(args, "--auth") ?? "none";
|
|
1914
2138
|
const envs = flag3(args, "--envs")?.split(",") ?? ["beta", "prod"];
|
|
2139
|
+
const requireMigrationApproval = args.includes("--require-migration-approval");
|
|
1915
2140
|
const { path: regPath, config: reg } = await loadManifest();
|
|
1916
2141
|
if (regPath.endsWith(".example.ts")) {
|
|
1917
2142
|
throw new Error(
|
|
@@ -1922,12 +2147,36 @@ async function adoptCommand(args) {
|
|
|
1922
2147
|
throw new Error('"blog" is the apex site, not an adopted tool');
|
|
1923
2148
|
}
|
|
1924
2149
|
const domain = flag3(args, "--domain") ?? reg.domain;
|
|
1925
|
-
const ctx = {
|
|
2150
|
+
const ctx = {
|
|
2151
|
+
name,
|
|
2152
|
+
repoArg,
|
|
2153
|
+
lane,
|
|
2154
|
+
target,
|
|
2155
|
+
data,
|
|
2156
|
+
auth,
|
|
2157
|
+
envs,
|
|
2158
|
+
domain,
|
|
2159
|
+
requireMigrationApproval,
|
|
2160
|
+
reg,
|
|
2161
|
+
regPath
|
|
2162
|
+
};
|
|
1926
2163
|
if (args.includes("--standalone")) return adoptStandalone(ctx);
|
|
1927
2164
|
return adoptWrapper(ctx);
|
|
1928
2165
|
}
|
|
1929
2166
|
async function adoptWrapper(ctx) {
|
|
1930
|
-
const {
|
|
2167
|
+
const {
|
|
2168
|
+
name,
|
|
2169
|
+
repoArg,
|
|
2170
|
+
lane,
|
|
2171
|
+
target,
|
|
2172
|
+
data,
|
|
2173
|
+
auth,
|
|
2174
|
+
envs,
|
|
2175
|
+
domain,
|
|
2176
|
+
requireMigrationApproval,
|
|
2177
|
+
reg,
|
|
2178
|
+
regPath
|
|
2179
|
+
} = ctx;
|
|
1931
2180
|
const cwd = process.cwd();
|
|
1932
2181
|
const toolRel = `tools/${name}`;
|
|
1933
2182
|
const dest = resolve6(cwd, toolRel);
|
|
@@ -1957,17 +2206,18 @@ async function adoptWrapper(ctx) {
|
|
|
1957
2206
|
dir: toolRel,
|
|
1958
2207
|
external: true,
|
|
1959
2208
|
adopted: true,
|
|
1960
|
-
// oci
|
|
1961
|
-
// (`greenlight preview <name>`) works. Default: a docker `preview`
|
|
1962
|
-
// transport (the tool adds that profile to its compose). Edit
|
|
1963
|
-
...target === "oci" ? {
|
|
2209
|
+
// Container targets (oci/docker) have no built-in local serve — scaffold a `preview` descriptor
|
|
2210
|
+
// so the uniform local gate (`greenlight preview <name>`) works. Default: a docker `preview`
|
|
2211
|
+
// profile matching the prod transport (the tool adds that profile to its compose). Edit to fit.
|
|
2212
|
+
...target === "oci" || target === "docker" ? {
|
|
1964
2213
|
preview: {
|
|
1965
2214
|
command: "docker compose --profile preview up",
|
|
1966
2215
|
teardown: "docker compose --profile preview down -v",
|
|
1967
2216
|
port: 8e3,
|
|
1968
2217
|
path: lane === "mcp" ? "/mcp" : ""
|
|
1969
2218
|
}
|
|
1970
|
-
} : {}
|
|
2219
|
+
} : {},
|
|
2220
|
+
...requireMigrationApproval ? { requireMigrationApproval: true } : {}
|
|
1971
2221
|
});
|
|
1972
2222
|
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
1973
2223
|
console.log(
|
|
@@ -1992,16 +2242,17 @@ async function adoptWrapper(ctx) {
|
|
|
1992
2242
|
}
|
|
1993
2243
|
materializeAgentKit(dest, { lane, target, data });
|
|
1994
2244
|
addGreenlightScript(dest);
|
|
1995
|
-
if (target === "oci") {
|
|
2245
|
+
if (target === "oci" || target === "docker") {
|
|
1996
2246
|
const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
|
|
2247
|
+
const deploySteps = target === "docker" ? dockerDeployAndVerifySteps(name) : ociDeployAndVerifySteps(name);
|
|
1997
2248
|
writeIfAbsent(
|
|
1998
2249
|
join2(cwd, `.github/workflows/greenlight-deploy-${name}.yml`),
|
|
1999
|
-
deployListenerYml(name, slug),
|
|
2250
|
+
deployListenerYml(name, slug, deploySteps),
|
|
2000
2251
|
`.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
|
|
2001
2252
|
);
|
|
2002
2253
|
writeIfAbsent(
|
|
2003
2254
|
join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
|
|
2004
|
-
remediateYml(name),
|
|
2255
|
+
target === "docker" ? dockerRemediateYml(name) : remediateYml(name),
|
|
2005
2256
|
`.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
|
|
2006
2257
|
);
|
|
2007
2258
|
writeIfAbsent(
|
|
@@ -2022,6 +2273,13 @@ async function adoptWrapper(ctx) {
|
|
|
2022
2273
|
`${toolRel}/.github/workflows/greenlight-verify.yml (verify on Vercel deployment_status)`
|
|
2023
2274
|
);
|
|
2024
2275
|
}
|
|
2276
|
+
if (requireMigrationApproval && (data === "supabase" || data === "neon")) {
|
|
2277
|
+
writeIfAbsent(
|
|
2278
|
+
join2(dest, `.github/workflows/greenlight-migrate-${name}.yml`),
|
|
2279
|
+
migrateWorkflowYml(name),
|
|
2280
|
+
`${toolRel}/.github/workflows/greenlight-migrate-${name}.yml (gated prod migration)`
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2025
2283
|
console.log(`
|
|
2026
2284
|
Next:
|
|
2027
2285
|
(in the tool repo) commit the Greenlight kit + build workflow so they travel with the submodule:
|
|
@@ -2031,14 +2289,30 @@ Next:
|
|
|
2031
2289
|
git commit && git push # CI (infra.yml) applies. Tool's CI builds; wrapper deploys.${target === "oci" ? `
|
|
2032
2290
|
Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # TF_VAR_OCI_* + GREENLIGHT_STATUS_TOKEN
|
|
2033
2291
|
greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
|
|
2034
|
-
The instance OCID is auto-resolved by the deploy workflow (by display name) \u2014 nothing to set.` : target === "
|
|
2292
|
+
The instance OCID is auto-resolved by the deploy workflow (by display name) \u2014 nothing to set.` : target === "docker" ? `
|
|
2293
|
+
Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # DOCKER_SSH_* + GREENLIGHT_STATUS_TOKEN
|
|
2294
|
+
greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
|
|
2295
|
+
On the host (one-time): a docker-compose with the GHCR image + a cloudflared service using the
|
|
2296
|
+
${name}_tunnel_token output; the deploy listener SSHes it and runs \`docker compose pull && up -d\`.` : target === "vercel" ? `
|
|
2035
2297
|
Deploy is Vercel's git integration (no wrapper deploy). The tool's greenlight-verify.yml verifies
|
|
2036
2298
|
each deployment (deployment_status). Optional secrets on ${slug}:
|
|
2037
2299
|
\xB7 VERCEL_AUTOMATION_BYPASS_SECRET_${name.toUpperCase().replace(/-/g, "_")} (Vercel \u2192 project \u2192 Deployment Protection \u2192 Bypass for Automation) \u2192 verify asserts 200, not 401
|
|
2038
2300
|
\xB7 ANTHROPIC_API_KEY \u2192 enables the agent-web scenarios in verify/${name}.config.ts (absent \u2192 api gate alone)` : ""}`);
|
|
2039
2301
|
}
|
|
2040
2302
|
async function adoptStandalone(ctx) {
|
|
2041
|
-
const {
|
|
2303
|
+
const {
|
|
2304
|
+
name,
|
|
2305
|
+
repoArg,
|
|
2306
|
+
lane,
|
|
2307
|
+
target,
|
|
2308
|
+
data,
|
|
2309
|
+
auth,
|
|
2310
|
+
envs,
|
|
2311
|
+
domain,
|
|
2312
|
+
requireMigrationApproval,
|
|
2313
|
+
reg,
|
|
2314
|
+
regPath
|
|
2315
|
+
} = ctx;
|
|
2042
2316
|
const repo = resolve6(process.cwd(), repoArg);
|
|
2043
2317
|
if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
|
|
2044
2318
|
const regVendor = resolve6(process.cwd(), "vendor");
|
|
@@ -2050,7 +2324,17 @@ async function adoptStandalone(ctx) {
|
|
|
2050
2324
|
}
|
|
2051
2325
|
console.log(`adopting "${name}" (${lane}/${target}) into ${repo} (standalone)
|
|
2052
2326
|
`);
|
|
2053
|
-
const toolEntry = {
|
|
2327
|
+
const toolEntry = {
|
|
2328
|
+
name,
|
|
2329
|
+
lane,
|
|
2330
|
+
target,
|
|
2331
|
+
data,
|
|
2332
|
+
auth,
|
|
2333
|
+
envs,
|
|
2334
|
+
dir: ".",
|
|
2335
|
+
adopted: true,
|
|
2336
|
+
...requireMigrationApproval ? { requireMigrationApproval: true } : {}
|
|
2337
|
+
};
|
|
2054
2338
|
const toolConfig = addTool({ domain, alerts: { sink: "github-issue" }, tools: [] }, toolEntry);
|
|
2055
2339
|
writeIfAbsent(
|
|
2056
2340
|
join2(repo, "greenlight.config.ts"),
|
|
@@ -2088,6 +2372,13 @@ async function adoptStandalone(ctx) {
|
|
|
2088
2372
|
".github/workflows/greenlight-promote.yml"
|
|
2089
2373
|
);
|
|
2090
2374
|
writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
|
|
2375
|
+
if (requireMigrationApproval && (data === "supabase" || data === "neon")) {
|
|
2376
|
+
writeIfAbsent(
|
|
2377
|
+
join2(repo, `.github/workflows/greenlight-migrate-${name}.yml`),
|
|
2378
|
+
migrateWorkflowYml(name),
|
|
2379
|
+
`.github/workflows/greenlight-migrate-${name}.yml (gated prod migration)`
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2091
2382
|
materializeAgentKit(repo, { lane, target, data });
|
|
2092
2383
|
writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
|
|
2093
2384
|
writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
|
|
@@ -2099,7 +2390,8 @@ async function adoptStandalone(ctx) {
|
|
|
2099
2390
|
auth,
|
|
2100
2391
|
envs,
|
|
2101
2392
|
external: true,
|
|
2102
|
-
adopted: true
|
|
2393
|
+
adopted: true,
|
|
2394
|
+
...requireMigrationApproval ? { requireMigrationApproval: true } : {}
|
|
2103
2395
|
});
|
|
2104
2396
|
writeFileSync3(regPath, serializeConfig(nextReg));
|
|
2105
2397
|
console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
|
|
@@ -2237,6 +2529,8 @@ async function configCommand() {
|
|
|
2237
2529
|
|
|
2238
2530
|
// ../packages/adapters/src/index.ts
|
|
2239
2531
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
2532
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
2533
|
+
import { tmpdir } from "os";
|
|
2240
2534
|
import { join as join4 } from "path";
|
|
2241
2535
|
function run(cmd, args, cwd, extraEnv) {
|
|
2242
2536
|
execFileSync3(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...extraEnv } });
|
|
@@ -2298,6 +2592,63 @@ function ociAdapter(ctx) {
|
|
|
2298
2592
|
}
|
|
2299
2593
|
};
|
|
2300
2594
|
}
|
|
2595
|
+
function dockerConfig(name, source = process.env) {
|
|
2596
|
+
return {
|
|
2597
|
+
host: source.DOCKER_SSH_HOST,
|
|
2598
|
+
user: source.DOCKER_SSH_USER || "root",
|
|
2599
|
+
port: source.DOCKER_SSH_PORT || "22",
|
|
2600
|
+
remoteDir: source.DOCKER_COMPOSE_DIR || `greenlight/${name ?? "app"}`,
|
|
2601
|
+
key: source.DOCKER_SSH_KEY
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
function sshDeployArgs(cfg, identityPath) {
|
|
2605
|
+
const remoteCmd = `cd ${cfg.remoteDir} && docker compose pull && docker compose up -d`;
|
|
2606
|
+
return [
|
|
2607
|
+
"-i",
|
|
2608
|
+
identityPath,
|
|
2609
|
+
"-p",
|
|
2610
|
+
cfg.port,
|
|
2611
|
+
"-o",
|
|
2612
|
+
"StrictHostKeyChecking=accept-new",
|
|
2613
|
+
"-o",
|
|
2614
|
+
"BatchMode=yes",
|
|
2615
|
+
`${cfg.user}@${cfg.host}`,
|
|
2616
|
+
remoteCmd
|
|
2617
|
+
];
|
|
2618
|
+
}
|
|
2619
|
+
function dockerAdapter(ctx) {
|
|
2620
|
+
const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
|
|
2621
|
+
return {
|
|
2622
|
+
target: "docker",
|
|
2623
|
+
async build() {
|
|
2624
|
+
return { artifactDir: "." };
|
|
2625
|
+
},
|
|
2626
|
+
async deploy(_toolDir, env) {
|
|
2627
|
+
const cfg = dockerConfig(ctx.name);
|
|
2628
|
+
if (!cfg.host) throw new Error("docker deploy needs DOCKER_SSH_HOST (the host you own)");
|
|
2629
|
+
if (!cfg.key) {
|
|
2630
|
+
throw new Error("docker deploy needs DOCKER_SSH_KEY (the deploy user's private key)");
|
|
2631
|
+
}
|
|
2632
|
+
const dir = mkdtempSync(join4(tmpdir(), "gl-ssh-"));
|
|
2633
|
+
const keyPath = join4(dir, "id");
|
|
2634
|
+
try {
|
|
2635
|
+
writeFileSync6(keyPath, cfg.key.endsWith("\n") ? cfg.key : `${cfg.key}
|
|
2636
|
+
`);
|
|
2637
|
+
chmodSync(keyPath, 384);
|
|
2638
|
+
run("ssh", sshDeployArgs(cfg, keyPath), ".");
|
|
2639
|
+
} finally {
|
|
2640
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2641
|
+
}
|
|
2642
|
+
return { url: url(env) };
|
|
2643
|
+
},
|
|
2644
|
+
url,
|
|
2645
|
+
async teardown() {
|
|
2646
|
+
throw new Error(
|
|
2647
|
+
"docker teardown is on the host \u2014 `ssh \u2026 docker compose down` in the tool dir."
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2301
2652
|
function vercelSkeletonAdapter(ctx) {
|
|
2302
2653
|
const url = (env) => resolveUrl({ domain: ctx.domain, name: ctx.name, env });
|
|
2303
2654
|
const notWired = () => {
|
|
@@ -2319,6 +2670,8 @@ function createAdapter(target, ctx) {
|
|
|
2319
2670
|
return workersAdapter(ctx);
|
|
2320
2671
|
case "oci":
|
|
2321
2672
|
return ociAdapter(ctx);
|
|
2673
|
+
case "docker":
|
|
2674
|
+
return dockerAdapter(ctx);
|
|
2322
2675
|
case "vercel":
|
|
2323
2676
|
return vercelSkeletonAdapter(ctx);
|
|
2324
2677
|
}
|
|
@@ -2340,7 +2693,7 @@ async function deployCommand(args) {
|
|
|
2340
2693
|
}
|
|
2341
2694
|
const { config } = await loadManifest();
|
|
2342
2695
|
const entry = resolveEntry(config, name);
|
|
2343
|
-
if (entry.external && entry.target !== "oci") {
|
|
2696
|
+
if (entry.external && entry.target !== "oci" && entry.target !== "docker") {
|
|
2344
2697
|
throw new Error(`"${name}" is external (registry pointer) \u2014 deploy it from its own repo`);
|
|
2345
2698
|
}
|
|
2346
2699
|
const adapter = createAdapter(entry.target, { domain: config.domain, name: entry.name });
|
|
@@ -2501,6 +2854,18 @@ function conformanceChecks(t, root) {
|
|
|
2501
2854
|
});
|
|
2502
2855
|
}
|
|
2503
2856
|
}
|
|
2857
|
+
if (t.requireMigrationApproval) {
|
|
2858
|
+
const migrateWf = `greenlight-migrate-${t.name}.yml`;
|
|
2859
|
+
const hasWf = [
|
|
2860
|
+
join6(root, toolDir, ".github/workflows", migrateWf),
|
|
2861
|
+
join6(root, ".github/workflows", migrateWf)
|
|
2862
|
+
].some((p) => existsSync9(p));
|
|
2863
|
+
out.push({
|
|
2864
|
+
name: `${t.name}: migration approval`,
|
|
2865
|
+
status: hasWf ? "ok" : "warn",
|
|
2866
|
+
detail: hasWf ? `${migrateWf} present \u2014 set required reviewers on the ${t.name}-prod environment (prod_reviewers)` : `requireMigrationApproval set but ${migrateWf} is missing \u2014 re-run adopt with --require-migration-approval, and set prod_reviewers on the ${t.name}-prod environment`
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2504
2869
|
return out;
|
|
2505
2870
|
}
|
|
2506
2871
|
function versionDriftCheck(root) {
|
|
@@ -2567,11 +2932,13 @@ function runDoctor(config, root) {
|
|
|
2567
2932
|
}
|
|
2568
2933
|
checks.push(...conformanceChecks(t, root));
|
|
2569
2934
|
}
|
|
2570
|
-
const needsKeepalive = config.tools.filter(
|
|
2935
|
+
const needsKeepalive = config.tools.filter(
|
|
2936
|
+
(t) => t.data === "supabase" || t.target === "oci" || t.target === "docker"
|
|
2937
|
+
);
|
|
2571
2938
|
checks.push({
|
|
2572
2939
|
name: "keepalive coverage",
|
|
2573
2940
|
status: needsKeepalive.length > 0 ? "ok" : "skip",
|
|
2574
|
-
detail: needsKeepalive.length > 0 ? needsKeepalive.map((t) => `${t.name} (${t.data === "supabase" ? "supabase" :
|
|
2941
|
+
detail: needsKeepalive.length > 0 ? needsKeepalive.map((t) => `${t.name} (${t.data === "supabase" ? "supabase" : t.target})`).join(", ") : "no data:supabase / target:oci|docker tools"
|
|
2575
2942
|
});
|
|
2576
2943
|
checks.push(versionDriftCheck(root));
|
|
2577
2944
|
checks.push(submoduleDriftCheck(root));
|
|
@@ -2651,7 +3018,7 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}${warned ?
|
|
|
2651
3018
|
}
|
|
2652
3019
|
|
|
2653
3020
|
// src/commands/init.ts
|
|
2654
|
-
import { existsSync as existsSync10, mkdirSync as mkdirSync4, writeFileSync as
|
|
3021
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync7 } from "fs";
|
|
2655
3022
|
import { resolve as resolve8 } from "path";
|
|
2656
3023
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
2657
3024
|
|
|
@@ -2707,6 +3074,13 @@ ${key} \u2014 ${spec.label}`);
|
|
|
2707
3074
|
`${key} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
|
|
2708
3075
|
);
|
|
2709
3076
|
}
|
|
3077
|
+
if (!check.ok) {
|
|
3078
|
+
console.log(
|
|
3079
|
+
` \xB7 ${key} not pushed (verify failed${check.detail ? `: ${check.detail}` : ""})`
|
|
3080
|
+
);
|
|
3081
|
+
results.push({ envVar: spec.envVar, outcome: "skipped", verify: check });
|
|
3082
|
+
continue;
|
|
3083
|
+
}
|
|
2710
3084
|
}
|
|
2711
3085
|
setGitHubSecret(repo, opts.env, key, entered);
|
|
2712
3086
|
results.push({ envVar: spec.envVar, outcome: "entered", verify: check });
|
|
@@ -2807,7 +3181,7 @@ function scaffoldIfAbsent(path, contents, label) {
|
|
|
2807
3181
|
return;
|
|
2808
3182
|
}
|
|
2809
3183
|
mkdirSync4(resolve8(path, ".."), { recursive: true });
|
|
2810
|
-
|
|
3184
|
+
writeFileSync7(path, contents);
|
|
2811
3185
|
console.log(`\u2714 wrote ${label}`);
|
|
2812
3186
|
}
|
|
2813
3187
|
var TOKEN_FLAGS = {
|
|
@@ -2832,7 +3206,7 @@ async function initCommand(args) {
|
|
|
2832
3206
|
if (existsSync10(configPath) && !force) {
|
|
2833
3207
|
throw new Error("greenlight.config.ts already exists \u2014 pass --force to overwrite");
|
|
2834
3208
|
}
|
|
2835
|
-
|
|
3209
|
+
writeFileSync7(configPath, scaffoldConfig(domain));
|
|
2836
3210
|
console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
|
|
2837
3211
|
const repoName = domain.replace(/\./g, "-");
|
|
2838
3212
|
scaffoldIfAbsent(
|
|
@@ -87,11 +87,12 @@ ${tree}`.slice(0, 6e3);
|
|
|
87
87
|
async function evalAsserts(page, asserts) {
|
|
88
88
|
const checks = [];
|
|
89
89
|
let text = "";
|
|
90
|
+
let textError = "";
|
|
90
91
|
if (asserts.some((a) => a.textContains)) {
|
|
91
92
|
try {
|
|
92
93
|
text = await page.locator("body").innerText({ timeout: 3e3 });
|
|
93
|
-
} catch {
|
|
94
|
-
|
|
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.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { GreenlightConfig, defineConfig, loadConfig } from '@rtrentjones/greenlight-shared';
|
|
1
|
+
export { GreenlightConfig, ResolveUrlOptions, defineConfig, loadConfig, resolveUrl } from '@rtrentjones/greenlight-shared';
|
|
2
2
|
export { VerifySpec, defineVerify } from '@rtrentjones/greenlight-verify';
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
defineConfig,
|
|
3
3
|
defineVerify,
|
|
4
|
-
loadConfig
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
loadConfig,
|
|
5
|
+
resolveUrl
|
|
6
|
+
} from "./chunk-NFNVF4HV.js";
|
|
7
|
+
import "./chunk-UMY5ZDOQ.js";
|
|
7
8
|
import "./chunk-N3IKUCSF.js";
|
|
8
9
|
import "./chunk-KP3Y6WRU.js";
|
|
9
|
-
import "./chunk-
|
|
10
|
-
import "./chunk-
|
|
10
|
+
import "./chunk-2LAYOVFP.js";
|
|
11
|
+
import "./chunk-C6NJHCRI.js";
|
|
11
12
|
import "./chunk-QFKE5JKC.js";
|
|
12
13
|
export {
|
|
13
14
|
defineConfig,
|
|
14
15
|
defineVerify,
|
|
15
|
-
loadConfig
|
|
16
|
+
loadConfig,
|
|
17
|
+
resolveUrl
|
|
16
18
|
};
|
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-shared": "0.
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
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",
|