@rtrentjones/greenlight 0.2.14 → 0.2.16

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.
@@ -1,53 +1,72 @@
1
1
  ---
2
2
  name: deploy-verify-promote
3
- description: Ship a change through Greenlight's loopdeploy to preview/beta, verify with the shared harness, then gated-promote developmain to prod. Use when making a change to a Greenlight tool or the blog and you want it shipped with confidence, or when explicitly asked to deploy/verify/promote.
3
+ description: The one model for delivering a feature to ANY Greenlight toollocal-gate (preview) → add it to the verify loop → ship (gated on the tool's own tests)verify prod. Same shape for blog (workers), web apps (vercel), and MCP servers (oci); only the matrix cells vary. Use when changing a Greenlight tool or the blog and you want it shipped with objective confidence, or when asked to deploy/verify/promote.
4
4
  ---
5
5
 
6
- # deploy-verify-promote
6
+ # deploy-verify-promote — one loop for every tool
7
7
 
8
- The execution discipline for changing a Greenlight tool or the blog. The verify
9
- harness and promote guard are the **same code CI runs**, so passing locally means
10
- passing in CI. This is what lets a change (or a long string of changes) be shipped
11
- with objective confidence rather than vibes.
8
+ The execution discipline for delivering a feature to **any** Greenlight tool. The verify harness and
9
+ promote guard are the **same code CI runs**, so passing locally means passing in CI. This is what
10
+ lets a change (or a long autonomous string of changes) ship with objective confidence, not vibes.
12
11
 
13
- ## Input
12
+ **One shape, every tool** — only the matrix cells differ (never the steps):
14
13
 
15
- - `<name>` — a manifest entry: `blog`, or a tool name from `greenlight.config.ts`.
14
+ ```
15
+ branch → change → LOCAL GATE (greenlight preview) → ADD IT TO THE VERIFY LOOP (the tool's
16
+ verify.config) → SHIP (push; CI gates on the tool's own tests) → DEPLOY → VERIFY PROD
17
+ ```
16
18
 
17
- ## Deterministic URL scheme (never scrape deploy logs)
19
+ ## Input
20
+ - `<name>` — a manifest entry: `blog`, or a tool from `greenlight.config.ts`.
18
21
 
19
- | Subject | prod | beta |
20
- |---|---|---|
21
- | tool | `https://<name>.<domain>` | `https://beta.<name>.<domain>` |
22
- | blog (apex) | `https://<domain>` | `https://beta.<domain>` |
23
- | mcp connect | *(tool url)* `+ /mcp` | same `+ /mcp` |
22
+ ## The matrix (what varies by lane×target — look up your tool, then follow the one procedure)
24
23
 
25
- `preview` is per-target and comes from the adapter's `deploy()` result. Everything
26
- else is computed by `resolveUrl` in `@rtrentjones/greenlight-shared`.
24
+ | lane×target | local gate (`preview`) | ship trigger | beta? / promote? | deploy | verify mode | verify config lives | code repo |
25
+ |---|---|---|---|---|---|---|---|
26
+ | astro/workers (blog) | build + `pnpm preview` | push `develop`/`main` | **yes / yes** | wrangler | api(+playwright) | `<dir>/verify.config.ts` | same repo |
27
+ | next/vercel (web app) | `preview` descriptor / build | git push (Vercel) | **yes (preview) / yes** | Vercel git-integration | api/agent-web/test | tool repo `verify/<name>.config.ts` | cross-repo (submodule) |
28
+ | mcp/oci (MCP server) | `preview` descriptor (docker `/mcp`) | push → build → dispatch | **no / no** (direct-to-prod) | restart instance | mcp(+eval) | wrapper `verify/<name>.config.ts` | cross-repo (submodule) |
29
+ | mcp/workers (dev) | `pnpm start` `/mcp` | push | yes / yes | wrangler | mcp | `<dir>/verify.config.ts` | same repo |
27
30
 
28
- ## Procedure
31
+ Two axes cause all the variation:
32
+ - **Standing beta + promote** (web: a cheap preview/beta exists → verify beta, then gated FF
33
+ `develop→main`) vs **direct-to-prod, verify-gated** (oci: no beta on the free tier → the
34
+ **local gate + the ship-gate are your pre-prod safety**; deploy restarts prod, then verify prod).
35
+ - **Same-repo** vs **cross-repo adopted** (the tool's code is a `tools/<name>` submodule; its infra
36
+ + verify config live in the **wrapper**; you edit both and **bump the submodule pointer**).
29
37
 
30
- 1. **Branch** `git checkout -b <type>/<slug>` (e.g. `post/hello`, `fix/mcp-auth`).
31
- 2. **Make the change.**
32
- 3. **Preview** — push; the target's git integration produces a preview deploy. Verify it.
33
- - Local/CI: `runLoop` (build → deploy → verify) from `@rtrentjones/greenlight-loop`.
34
- - Standalone / local server: `pnpm greenlight verify <name> --url <preview-or-localhost-url>`.
35
- 4. **Beta** — merge to `develop` → beta deploy. `pnpm greenlight verify <name> --env beta`.
36
- Mode is chosen by lane: `api`/`playwright` for web, `mcp` for MCP servers.
37
- 5. **Promote** — `pnpm greenlight promote <name>`. Checks the fast-forward guard
38
- (`develop → main`). If it refuses (diverged `main`), reconcile and retry — never force-push.
39
- 6. **Prod** — after promote, `pnpm greenlight verify <name> --env prod`.
38
+ ## Procedure (identical for every tool)
40
39
 
41
- ## Rules
40
+ 1. **Branch** in the tool's code repo — `git checkout -b <type>/<slug>` (`feat/new-tool`, `fix/auth`).
41
+ 2. **Make the change** (+ the tool's own tests — they become the ship-gate in step 4).
42
+ 3. **LOCAL GATE** — `pnpm greenlight preview <name>`: spins the tool up locally (matching its prod
43
+ contract) and runs the verify harness against it. Green here = your pre-prod signal (essential for
44
+ direct-to-prod oci, which has no beta). `preview` sets `GREENLIGHT_PREVIEW=1` so a config can pick
45
+ a local-appropriate spec (e.g. skip an auth-rejection a local no-auth server can't satisfy).
46
+ 4. **ADD IT TO THE VERIFY LOOP** — edit the tool's `verify.config.ts` (location per the matrix) so
47
+ the new capability is asserted: add the tool to `expectTools` (mcp; `exactTools: true` makes a
48
+ forgotten entry fail the gate), a `check`/`renders`/`suite` (web), or an `eval` case (quality).
49
+ 5. **SHIP** — push. CI **gates on the tool's own tests** before anything deploys (container build
50
+ `needs: [test]`; vercel via `deployment_status`; workers via deploy→verify). A broken change never
51
+ reaches prod.
52
+ 6. **DEPLOY + VERIFY PROD** —
53
+ - **web (beta+promote):** merge to `develop` → beta; `greenlight verify <name> --env beta`; then
54
+ `greenlight promote <name>` (gated FF `develop→main` — never force-push); `verify --env prod`.
55
+ - **oci (direct-to-prod):** the push builds → dispatches → the wrapper restarts the instance →
56
+ `greenlight verify <name> --env prod` runs automatically as the deploy gate.
57
+ 7. **Watch** — `pnpm greenlight status <name>` shows the last build/deploy/verify run across repos.
42
58
 
43
- - `verify` exits non-zero if any check fails; the report lists each. **Never promote a
44
- tool whose beta verify is failing.**
59
+ ## Rules
60
+ - `verify` exits non-zero if any check fails; the report lists each. **Never promote/ship a tool
61
+ whose verify is failing.**
62
+ - **Always run the local gate (step 3) before pushing a direct-to-prod (oci) tool** — there is no
63
+ beta to catch a bad image; the deploy restarts prod.
64
+ - Cross-repo: commit the tool change in its submodule AND bump the submodule pointer in the wrapper;
65
+ the verify config + infra are the **wrapper's** to edit.
45
66
  - Connect URL for MCP tools is the tool URL + `/mcp`; `verify` handles this by lane.
46
- - Real per-target deploys are wired in phases (greenlight-v1.md §16); the loop, verify,
47
- and promote guard are stable now.
67
+ - `greenlight doctor` flags any tool drifting from this model (no verify spec, no local preview gate).
48
68
 
49
69
  ## Cross-repo note
50
-
51
- In standalone repos (BAMCP, ejected tools) this skill is delivered by the **Greenlight
52
- Claude Code plugin** (Phase 7), and the mechanics by the `@rtrentjones/greenlight*` npm
53
- deps; the per-repo parameters come from that repo's `greenlight.config.ts`.
70
+ In adopted/standalone repos (BAMCP, ejected tools) this skill is delivered by the **Greenlight
71
+ Claude Code plugin** and the mechanics by the `@rtrentjones/greenlight*` npm deps; the per-repo
72
+ parameters come from that repo's (or the wrapper's) `greenlight.config.ts`.
package/dist/bin.js CHANGED
@@ -5,9 +5,9 @@ import {
5
5
  loadConfig,
6
6
  resolveUrl,
7
7
  verifyAll
8
- } from "./chunk-JRCATCRY.js";
9
- import "./chunk-ADS6BJJ5.js";
10
- import "./chunk-WFZTRXBF.js";
8
+ } from "./chunk-6USV5AQV.js";
9
+ import "./chunk-HX7VA25D.js";
10
+ import "./chunk-N3IKUCSF.js";
11
11
  import "./chunk-KP3Y6WRU.js";
12
12
  import "./chunk-UXHHLEYO.js";
13
13
  import "./chunk-6N7MD6FR.js";
@@ -49,6 +49,15 @@ function serializeTool(t) {
49
49
  if (t.dir !== void 0) parts.push(`dir: ${q(t.dir)}`);
50
50
  if (t.adopted) parts.push("adopted: true");
51
51
  if (t.external) parts.push("external: true");
52
+ if (t.port !== void 0) parts.push(`port: ${t.port}`);
53
+ if (t.preview) {
54
+ const pv = t.preview;
55
+ const pvParts = [`command: ${q(pv.command)}`];
56
+ if (pv.teardown !== void 0) pvParts.push(`teardown: ${q(pv.teardown)}`);
57
+ if (pv.port !== void 0) pvParts.push(`port: ${pv.port}`);
58
+ if (pv.path !== void 0) pvParts.push(`path: ${q(pv.path)}`);
59
+ parts.push(`preview: { ${pvParts.join(", ")} }`);
60
+ }
52
61
  return ` { ${parts.join(", ")} },`;
53
62
  }
54
63
  function serializeConfig(c) {
@@ -93,7 +102,8 @@ function addTool(config, t) {
93
102
  ...t.dir !== void 0 ? { dir: t.dir } : {},
94
103
  ...t.adopted ? { adopted: true } : {},
95
104
  ...t.external ? { external: true } : {},
96
- ...t.port !== void 0 ? { port: t.port } : {}
105
+ ...t.port !== void 0 ? { port: t.port } : {},
106
+ ...t.preview ? { preview: t.preview } : {}
97
107
  }
98
108
  ]
99
109
  };
@@ -118,7 +128,8 @@ function upsertTool(config, t) {
118
128
  ...t.dir !== void 0 ? { dir: t.dir } : {},
119
129
  ...t.adopted ? { adopted: true } : {},
120
130
  ...t.external ? { external: true } : {},
121
- ...t.port !== void 0 ? { port: t.port } : {}
131
+ ...t.port !== void 0 ? { port: t.port } : {},
132
+ ...t.preview ? { preview: t.preview } : {}
122
133
  };
123
134
  const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
124
135
  const result = ConfigSchema.safeParse({ ...config, tools });
@@ -173,7 +184,9 @@ function resolveEntry(config, name) {
173
184
  target: tool.target,
174
185
  data: tool.data,
175
186
  dir: tool.dir ?? `tools/${tool.name}`,
176
- external: tool.external
187
+ external: tool.external,
188
+ port: tool.port,
189
+ preview: tool.preview
177
190
  };
178
191
  }
179
192
  var VERIFY_MODES = /* @__PURE__ */ new Set(["api", "mcp", "playwright", "test", "agent-web", "eval"]);
@@ -413,7 +426,7 @@ function tokensForTool(tool) {
413
426
  }
414
427
 
415
428
  // src/version.ts
416
- var MODULE_REF = "v0.2.14";
429
+ var MODULE_REF = "v0.2.16";
417
430
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
418
431
  function moduleSource(module, ref = MODULE_REF) {
419
432
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -679,11 +692,15 @@ function mergeMcpServers(existing, add) {
679
692
  // src/commands/agent.ts
680
693
  var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
681
694
 
682
- This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
683
- branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
695
+ This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
696
+ tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
697
+ branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
698
+ push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
699
+ Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
700
+ pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
684
701
 
685
702
  Agentic kit:
686
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
703
+ - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
687
704
  - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
688
705
  Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
689
706
  - Best-practice skills (one-time, user scope):
@@ -1104,12 +1121,36 @@ function vendorDeps(vendorDir) {
1104
1121
  }
1105
1122
  return out;
1106
1123
  }
1107
- function starterVerifyConfig(lane, target) {
1108
- const spec = lane === "mcp" ? "mode: 'mcp', expectTools: []" : "mode: 'api', checks: [{ path: '/', status: 200 }]";
1109
- const logHint = target === "vercel" ? 'vercel logs "$GREENLIGHT_VERIFY_URL" --token "$VERCEL_API_TOKEN" 2>&1 | head -40 || true' : 'curl -sS -i "$GREENLIGHT_VERIFY_URL" 2>&1 | head -30 || true';
1124
+ function starterVerifyConfig(lane, _target) {
1125
+ const logHint = 'curl -sS -i "$GREENLIGHT_VERIFY_URL" 2>&1 | head -30 || true';
1126
+ if (lane === "mcp") {
1127
+ return `// Greenlight verify spec \u2014 edit to assert this tool's real contract.
1128
+ // \`greenlight preview\` sets GREENLIGHT_PREVIEW=1 (local run, usually no auth); prod runs the full gate.
1129
+ const preview = process.env.GREENLIGHT_PREVIEW === '1';
1130
+
1131
+ export default [
1132
+ {
1133
+ mode: 'mcp',
1134
+ // List the tools this server exposes. exactTools makes the gate FAIL if a tool is added in code
1135
+ // but not here (or removed) \u2014 so "added to the verify loop" is enforced, not optional.
1136
+ expectTools: [],
1137
+ exactTools: true,
1138
+ // Auth-gated server? Require an unauthenticated request to be rejected \u2014 but not under preview
1139
+ // (a local no-auth server can't satisfy it). Add headers:{ Authorization: \`Bearer \${process.env.X}\` }
1140
+ // to run authenticated tools/list against prod.
1141
+ // requireAuthRejection: !preview,
1142
+ // logsOnFailure: '${logHint}',
1143
+ },
1144
+ // Quality (optional): LLM-judged tool output. Runs only with ANTHROPIC_API_KEY (degrades to a
1145
+ // failing check otherwise). Add cases:
1146
+ // { mode: 'eval', cases: [{ name: 'sensible', tool: 'your_tool', rubric: 'what good looks like' }] },
1147
+ ];
1148
+ `;
1149
+ }
1110
1150
  return `// Greenlight verify spec \u2014 edit to assert this tool's real contract.
1111
1151
  export default {
1112
- ${spec},
1152
+ mode: 'api',
1153
+ checks: [{ path: '/', status: 200 }],
1113
1154
  // Telemetry-into-verify: a shell command run ONLY when this report FAILS; its last ~50 lines
1114
1155
  // attach to the report so the agent/CI sees the "why" in-loop. $GREENLIGHT_VERIFY_URL is the
1115
1156
  // failing URL (no hard-coding). Best-effort (never fails the gate). Uncomment + adjust:
@@ -1251,12 +1292,13 @@ var MISE_TOML = `# Toolchain, managed by mise. \`mise install\` to set up.
1251
1292
  node = "24"
1252
1293
  pnpm = "10.12.1"
1253
1294
  `;
1254
- function containerBuildYml(name, wrapperRepo) {
1295
+ function containerBuildYml(name, wrapperRepo, testCommand = "make install && make test") {
1255
1296
  return `name: greenlight-build
1256
1297
 
1257
- # Provider-agnostic: build the container -> push to GHCR -> notify the wrapper to deploy.
1258
- # The wrapper owns the OCI infra + creds; this repo only needs GREENLIGHT_DISPATCH_TOKEN
1259
- # (a fine-grained PAT with "Contents: write" on ${wrapperRepo}) to fire the dispatch.
1298
+ # Provider-agnostic: TEST -> build the container -> push to GHCR -> notify the wrapper to deploy.
1299
+ # The ship is GATED on the tool's own tests (build \`needs: [test]\`). The wrapper owns the OCI infra
1300
+ # + creds; this repo only needs GREENLIGHT_DISPATCH_TOKEN (a fine-grained PAT with "Contents: write"
1301
+ # on ${wrapperRepo}) to fire the dispatch.
1260
1302
  on:
1261
1303
  push:
1262
1304
  branches: [main]
@@ -1271,7 +1313,16 @@ concurrency:
1271
1313
  cancel-in-progress: true
1272
1314
 
1273
1315
  jobs:
1316
+ test:
1317
+ # Ship-gate: the tool's own tests must pass before anything is built or deployed. Customize the
1318
+ # command + add the toolchain setup your tests need (setup-node / setup-python / mise) here.
1319
+ runs-on: ubuntu-latest
1320
+ steps:
1321
+ - uses: actions/checkout@v4
1322
+ - run: ${testCommand}
1323
+
1274
1324
  build:
1325
+ needs: [test]
1275
1326
  # Native arm64 runner \u2014 builds the arm64 image directly (no QEMU emulation, much faster).
1276
1327
  runs-on: ubuntu-24.04-arm
1277
1328
  steps:
@@ -1584,7 +1635,18 @@ async function adoptWrapper(ctx) {
1584
1635
  envs,
1585
1636
  dir: toolRel,
1586
1637
  external: true,
1587
- adopted: true
1638
+ adopted: true,
1639
+ // oci has no built-in local serve — scaffold a `preview` descriptor so the uniform local gate
1640
+ // (`greenlight preview <name>`) works. Default: a docker `preview` profile matching the prod
1641
+ // transport (the tool adds that profile to its compose). Edit the command/port/path to fit.
1642
+ ...target === "oci" ? {
1643
+ preview: {
1644
+ command: "docker compose --profile preview up",
1645
+ teardown: "docker compose --profile preview down -v",
1646
+ port: 8e3,
1647
+ path: lane === "mcp" ? "/mcp" : ""
1648
+ }
1649
+ } : {}
1588
1650
  });
1589
1651
  writeFileSync3(regPath, serializeConfig(nextReg));
1590
1652
  console.log(
@@ -1892,6 +1954,24 @@ import { join as join4 } from "path";
1892
1954
  function dirCheck(label, dir) {
1893
1955
  return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1894
1956
  }
1957
+ function conformanceChecks(t, root) {
1958
+ const out = [];
1959
+ const specRel = t.external ? `verify/${t.name}.config.ts` : join4(t.dir ?? join4("tools", t.name), "verify.config.ts");
1960
+ const hasSpec = existsSync7(join4(root, specRel));
1961
+ out.push({
1962
+ name: `${t.name}: in the verify loop`,
1963
+ status: hasSpec ? "ok" : "warn",
1964
+ detail: hasSpec ? specRel : `no ${specRel} \u2014 verify falls back to the lane default`
1965
+ });
1966
+ const builtIn = !t.external && t.target === "workers";
1967
+ const gateable = Boolean(t.preview) || builtIn;
1968
+ out.push({
1969
+ name: `${t.name}: local preview gate`,
1970
+ status: gateable ? "ok" : "warn",
1971
+ detail: gateable ? void 0 : `no built-in serve for ${t.external ? "an external " : ""}${t.target} tool \u2014 add preview:{ command, \u2026 } so \`greenlight preview ${t.name}\` works`
1972
+ });
1973
+ return out;
1974
+ }
1895
1975
  function runDoctor(config, root) {
1896
1976
  const checks = [];
1897
1977
  if (config.blog) checks.push(dirCheck("blog", join4(root, "apps/blog")));
@@ -1904,18 +1984,10 @@ function runDoctor(config, root) {
1904
1984
  mcp: t.lane === "mcp"
1905
1985
  });
1906
1986
  checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
1907
- continue;
1908
- }
1909
- const dir = join4(root, t.dir ?? join4("tools", t.name));
1910
- checks.push(dirCheck(t.name, dir));
1911
- if (t.lane === "mcp") {
1912
- const vc = join4(dir, "verify.config.ts");
1913
- checks.push({
1914
- name: `${t.name}: verify.config.ts`,
1915
- status: existsSync7(vc) ? "ok" : "warn",
1916
- detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1917
- });
1987
+ } else {
1988
+ checks.push(dirCheck(t.name, join4(root, t.dir ?? join4("tools", t.name))));
1918
1989
  }
1990
+ checks.push(...conformanceChecks(t, root));
1919
1991
  }
1920
1992
  const needsKeepalive = config.tools.filter((t) => t.data === "supabase" || t.target === "oci");
1921
1993
  checks.push({
@@ -2356,18 +2428,57 @@ async function waitForServer(url, timeoutMs = 3e4) {
2356
2428
  }
2357
2429
  return false;
2358
2430
  }
2359
- async function previewCommand(args) {
2360
- const name = args[0];
2361
- if (!name || name.startsWith("-")) {
2362
- throw new Error("usage: greenlight preview <name> [--port <n>]");
2363
- }
2364
- const portArg = flag7(args, "--port");
2365
- const { config } = await loadManifest();
2366
- const entry = resolveEntry(config, name);
2367
- if (entry.external) {
2368
- throw new Error(`"${name}" is external (registry pointer) \u2014 preview it from its own repo`);
2431
+ async function loadSpecs(entry) {
2432
+ const loaded = (entry.external && entry.name ? await loadExternalVerifySpec(entry.name) : await loadVerifySpec(entry.dir)) ?? defaultSpec(entry.lane);
2433
+ return Array.isArray(loaded) ? loaded : [loaded];
2434
+ }
2435
+ async function verifyLocal(entry, url) {
2436
+ process.env.GREENLIGHT_PREVIEW = "1";
2437
+ process.env.GREENLIGHT_VERIFY_URL = url;
2438
+ const specs = await loadSpecs(entry);
2439
+ const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2440
+ const reports = await verifyAll(url, specs, { toolDir });
2441
+ for (const report of reports) printReport(report);
2442
+ return allPass(reports);
2443
+ }
2444
+ async function previewViaDescriptor(entry, name, portOverride) {
2445
+ const pv = entry.preview;
2446
+ const lane = servePlan(entry.lane);
2447
+ const port = portOverride ?? pv.port ?? entry.port ?? lane.port;
2448
+ const path = pv.path ?? lane.path;
2449
+ const url = `http://localhost:${port}${path}`;
2450
+ const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2451
+ console.log(`preview ${name}: ${pv.command} (\u2192 ${url})`);
2452
+ const child = spawn(pv.command, {
2453
+ cwd: toolDir,
2454
+ shell: true,
2455
+ stdio: "inherit",
2456
+ detached: true,
2457
+ env: { ...process.env, PORT: String(port), GREENLIGHT_PREVIEW: "1" }
2458
+ });
2459
+ try {
2460
+ if (!await waitForServer(url, 12e4)) {
2461
+ throw new Error(`preview server did not become reachable at ${url} (check: ${pv.command})`);
2462
+ }
2463
+ return await verifyLocal(entry, url);
2464
+ } finally {
2465
+ if (pv.teardown) {
2466
+ try {
2467
+ execFileSync4(pv.teardown, { cwd: toolDir, shell: true, stdio: "inherit" });
2468
+ } catch {
2469
+ }
2470
+ }
2471
+ if (child.pid) {
2472
+ try {
2473
+ process.kill(-child.pid, "SIGTERM");
2474
+ } catch {
2475
+ child.kill("SIGTERM");
2476
+ }
2477
+ }
2369
2478
  }
2370
- const plan = servePlan(entry.lane, portArg ? Number(portArg) : void 0);
2479
+ }
2480
+ async function previewViaBuiltIn(entry, name, portOverride) {
2481
+ const plan = servePlan(entry.lane, portOverride);
2371
2482
  if (plan.build) {
2372
2483
  console.log(`build ${name} (${entry.dir})`);
2373
2484
  execFileSync4("pnpm", ["-C", entry.dir, "run", "build"], { stdio: "inherit" });
@@ -2380,7 +2491,6 @@ async function previewCommand(args) {
2380
2491
  stdio: "ignore",
2381
2492
  detached: true
2382
2493
  });
2383
- let pass = false;
2384
2494
  try {
2385
2495
  const base = `http://localhost:${plan.port}`;
2386
2496
  if (!await waitForServer(base)) {
@@ -2388,12 +2498,7 @@ async function previewCommand(args) {
2388
2498
  `server did not start on :${plan.port} (check the tool's ${plan.script} script)`
2389
2499
  );
2390
2500
  }
2391
- const loaded = await loadVerifySpec(entry.dir) ?? defaultSpec(entry.lane);
2392
- const specs = Array.isArray(loaded) ? loaded : [loaded];
2393
- const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2394
- const reports = await verifyAll(base + plan.path, specs, { toolDir });
2395
- for (const report of reports) printReport(report);
2396
- pass = allPass(reports);
2501
+ return await verifyLocal(entry, base + plan.path);
2397
2502
  } finally {
2398
2503
  if (child.pid) {
2399
2504
  try {
@@ -2403,6 +2508,26 @@ async function previewCommand(args) {
2403
2508
  }
2404
2509
  }
2405
2510
  }
2511
+ }
2512
+ async function previewCommand(args) {
2513
+ const name = args[0];
2514
+ if (!name || name.startsWith("-")) {
2515
+ throw new Error("usage: greenlight preview <name> [--port <n>]");
2516
+ }
2517
+ const portArg = flag7(args, "--port");
2518
+ const port = portArg ? Number(portArg) : void 0;
2519
+ const { config } = await loadManifest();
2520
+ const entry = resolveEntry(config, name);
2521
+ let pass;
2522
+ if (entry.preview) {
2523
+ pass = await previewViaDescriptor(entry, name, port);
2524
+ } else if (entry.external) {
2525
+ throw new Error(
2526
+ `"${name}" is external and has no preview descriptor \u2014 add preview:{ command, \u2026 } to its manifest entry (e.g. a docker command), or preview it from its own repo`
2527
+ );
2528
+ } else {
2529
+ pass = await previewViaBuiltIn(entry, name, port);
2530
+ }
2406
2531
  process.exit(pass ? 0 : 1);
2407
2532
  }
2408
2533
 
@@ -2482,6 +2607,86 @@ async function promoteCommand(args) {
2482
2607
  process.exit(result.promoted ? 0 : 1);
2483
2608
  }
2484
2609
 
2610
+ // src/commands/status.ts
2611
+ import { execFileSync as execFileSync6 } from "child_process";
2612
+ function repoSlug(dir) {
2613
+ try {
2614
+ const url = execFileSync6("git", ["-C", dir, "remote", "get-url", "origin"], {
2615
+ encoding: "utf8"
2616
+ }).trim();
2617
+ const m = url.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
2618
+ return m?.[1] ?? null;
2619
+ } catch {
2620
+ return null;
2621
+ }
2622
+ }
2623
+ function workflowsFor(entry, name, wrapper, toolRepo) {
2624
+ if (!entry.external) {
2625
+ return [{ repo: wrapper, workflow: "deploy.yml", label: "deploy + verify" }];
2626
+ }
2627
+ if (entry.target === "oci") {
2628
+ return [
2629
+ {
2630
+ repo: toolRepo,
2631
+ workflow: "greenlight-build.yml",
2632
+ label: "build (test \u2192 image \u2192 dispatch)"
2633
+ },
2634
+ { repo: wrapper, workflow: `greenlight-deploy-${name}.yml`, label: "deploy + verify (prod)" },
2635
+ { repo: wrapper, workflow: `greenlight-remediate-${name}.yml`, label: "self-heal" }
2636
+ ];
2637
+ }
2638
+ if (entry.target === "vercel") {
2639
+ return [
2640
+ { repo: toolRepo, workflow: "greenlight-verify.yml", label: "verify (deployment_status)" }
2641
+ ];
2642
+ }
2643
+ return [{ repo: toolRepo, workflow: "deploy.yml", label: "deploy + verify" }];
2644
+ }
2645
+ function lastRun(repo, workflow) {
2646
+ try {
2647
+ const out = execFileSync6(
2648
+ "gh",
2649
+ [
2650
+ "run",
2651
+ "list",
2652
+ "--repo",
2653
+ repo,
2654
+ "--workflow",
2655
+ workflow,
2656
+ "--limit",
2657
+ "1",
2658
+ "--json",
2659
+ "status,conclusion,displayTitle,url"
2660
+ ],
2661
+ { encoding: "utf8" }
2662
+ );
2663
+ const runs = JSON.parse(out);
2664
+ const r = runs[0];
2665
+ if (!r) return "no runs";
2666
+ const state = r.status === "completed" ? r.conclusion : r.status;
2667
+ const icon = state === "success" ? "\u2714" : state === "failure" ? "\u2718" : "\xB7";
2668
+ return `${icon} ${state} ${r.displayTitle}
2669
+ ${r.url}`;
2670
+ } catch (e) {
2671
+ return `(gh unavailable \u2014 ${e instanceof Error ? e.message.split("\n")[0] : "error"})`;
2672
+ }
2673
+ }
2674
+ async function statusCommand(args) {
2675
+ const name = args[0];
2676
+ if (!name || name.startsWith("-")) throw new Error("usage: greenlight status <name>");
2677
+ const { config } = await loadManifest();
2678
+ const entry = resolveEntry(config, name);
2679
+ const wrapper = repoSlug(process.cwd()) ?? "(this repo)";
2680
+ const toolRepo = entry.external ? repoSlug(entry.dir) ?? "(tool repo)" : wrapper;
2681
+ console.log(`status: ${name} (${entry.lane}/${entry.target})
2682
+ `);
2683
+ for (const w of workflowsFor(entry, name, wrapper, toolRepo)) {
2684
+ console.log(` ${w.label} [${w.repo} \xB7 ${w.workflow}]`);
2685
+ console.log(` ${lastRun(w.repo, w.workflow)}
2686
+ `);
2687
+ }
2688
+ }
2689
+
2485
2690
  // src/bin.ts
2486
2691
  var HELP = `greenlight <command>
2487
2692
 
@@ -2492,6 +2697,7 @@ var HELP = `greenlight <command>
2492
2697
  preview <name> [--port <n>] build + serve locally + verify (one command)
2493
2698
  verify <name> [--env <env> | --url <url>] run the verify harness against the URL
2494
2699
  promote <name> [--perform] [--push] gated develop -> main fast-forward
2700
+ status <name> last ship/deploy/verify run for a tool (via gh)
2495
2701
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
2496
2702
  secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
2497
2703
  agent sync write the loop skill + CLAUDE.md block into this repo
@@ -2523,6 +2729,8 @@ async function main() {
2523
2729
  return verifyCommand(args);
2524
2730
  case "promote":
2525
2731
  return promoteCommand(args);
2732
+ case "status":
2733
+ return statusCommand(args);
2526
2734
  case "secrets":
2527
2735
  return secretsCommand(args);
2528
2736
  case "agent":
@@ -42,7 +42,21 @@ var ToolSchema = z.object({
42
42
  dir: z.string().optional(),
43
43
  // The tool's code lives in another repo — this entry is a registry pointer only,
44
44
  // not built/deployed here (greenlight-v1.md §15.5 poly-repo).
45
- external: z.boolean().default(false)
45
+ external: z.boolean().default(false),
46
+ // How `greenlight preview` spins the tool up LOCALLY for the pre-deploy gate. Optional — node
47
+ // lanes (astro/next/mcp→workers) use the built-in build+serve path. Set it for targets with no
48
+ // built-in serve (e.g. oci: a docker command that matches the prod transport). The harness polls
49
+ // the local URL (http://localhost:<port><path>), verifies, then runs `teardown`.
50
+ preview: z.object({
51
+ command: z.string(),
52
+ // spin up locally in the background (e.g. a `docker compose … up`)
53
+ teardown: z.string().optional(),
54
+ // tear down afterwards (e.g. `docker compose … down`)
55
+ port: z.number().int().positive().optional(),
56
+ // local port (default: tool.port ?? lane default)
57
+ path: z.string().optional()
58
+ // connect path (default: lane default, e.g. `/mcp`)
59
+ }).optional()
46
60
  }).superRefine((tool, ctx) => {
47
61
  const rule = MATRIX[tool.lane];
48
62
  if (!rule.targets.includes(tool.target)) {
@@ -229,12 +243,12 @@ async function verify(baseUrl, spec, opts) {
229
243
  case "api":
230
244
  return verifyApi(baseUrl, spec);
231
245
  case "mcp": {
232
- const { verifyMcp: verifyMcp2 } = await import("./mcp-3L6HJ6BH.js");
246
+ const { verifyMcp: verifyMcp2 } = await import("./mcp-FFLOX4YP.js");
233
247
  return verifyMcp2(baseUrl, spec);
234
248
  }
235
249
  case "playwright": {
236
- const { verifyPlaywright: verifyPlaywright2 } = await import("./playwright-CGTTHGIL.js");
237
- return verifyPlaywright2(baseUrl, spec);
250
+ const { verifyPlaywright: verifyPlaywright2 } = await import("./playwright-SGRK3I4I.js");
251
+ return verifyPlaywright2(baseUrl, spec, opts?.toolDir ?? process.cwd());
238
252
  }
239
253
  case "test": {
240
254
  const { verifyTest: verifyTest2 } = await import("./test-7GMOU7I5.js");
@@ -32,6 +32,20 @@ async function verifyMcp(baseUrl, spec) {
32
32
  detail: has ? void 0 : `got [${names.join(", ")}]`
33
33
  });
34
34
  }
35
+ if (spec.exactTools) {
36
+ const expected = new Set(spec.expectTools);
37
+ const extra = names.filter((n) => !expected.has(n));
38
+ const missing = spec.expectTools.filter((t) => !names.includes(t));
39
+ const drift = [
40
+ extra.length ? `unexpected: [${extra.join(", ")}]` : "",
41
+ missing.length ? `missing: [${missing.join(", ")}]` : ""
42
+ ].filter(Boolean).join("; ");
43
+ checks.push({
44
+ name: "tools/list matches expectTools exactly",
45
+ pass: drift.length === 0,
46
+ detail: drift || void 0
47
+ });
48
+ }
35
49
  } catch (e) {
36
50
  checks.push({ name: "tools/list", pass: false, detail: msg(e) });
37
51
  }
@@ -0,0 +1,122 @@
1
+ import {
2
+ msg,
3
+ report
4
+ } from "./chunk-QFKE5JKC.js";
5
+
6
+ // ../packages/verify/src/playwright.ts
7
+ import { spawnSync } from "child_process";
8
+ async function verifyPlaywright(baseUrl, spec, toolDir = process.cwd()) {
9
+ const checks = [];
10
+ if (spec.suite) checks.push(runSuite(baseUrl, spec.suite, toolDir));
11
+ if (spec.renders?.length) checks.push(...await runRenders(baseUrl, spec.renders));
12
+ if (checks.length === 0) {
13
+ checks.push({
14
+ name: "playwright spec",
15
+ pass: false,
16
+ detail: "nothing to run \u2014 set `renders` and/or `suite`"
17
+ });
18
+ }
19
+ return report("playwright", baseUrl, checks);
20
+ }
21
+ function summarize(output) {
22
+ const lines = output.split("\n");
23
+ const hit = lines.find(
24
+ (l) => /\d+\s+(passed|failed|flaky|skipped)|Tests?\s+\d+\s+(passed|failed)|Tests:/.test(l)
25
+ );
26
+ if (hit) return hit.trim();
27
+ for (let i = lines.length - 1; i >= 0; i--) {
28
+ const l = lines[i];
29
+ if (l?.trim()) return l.trim();
30
+ }
31
+ return void 0;
32
+ }
33
+ function runSuite(baseUrl, suite, toolDir) {
34
+ const command = suite.command ?? "pnpm exec playwright test";
35
+ const cwd = suite.cwd ?? toolDir;
36
+ try {
37
+ const res = spawnSync(command, {
38
+ cwd,
39
+ shell: true,
40
+ encoding: "utf8",
41
+ timeout: suite.timeoutMs ?? 6e5,
42
+ maxBuffer: 64 * 1024 * 1024,
43
+ env: {
44
+ ...process.env,
45
+ // The deployed URL the suite must target. PLAYWRIGHT_BASE_URL is what a stock
46
+ // playwright.config reads for `use.baseURL`; GREENLIGHT_VERIFY_URL mirrors the rest of the
47
+ // harness so one convention works everywhere.
48
+ PLAYWRIGHT_BASE_URL: baseUrl,
49
+ GREENLIGHT_VERIFY_URL: baseUrl,
50
+ ...suite.env
51
+ }
52
+ });
53
+ if (res.error) {
54
+ return { name: command, pass: false, detail: msg(res.error) };
55
+ }
56
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`;
57
+ const summary = summarize(out);
58
+ const pass = res.status === 0;
59
+ return {
60
+ name: `suite: ${command}`,
61
+ pass,
62
+ detail: pass ? summary : `exit ${res.status ?? "signal"}${summary ? ` \u2014 ${summary}` : ""}`
63
+ };
64
+ } catch (e) {
65
+ return { name: command, pass: false, detail: msg(e) };
66
+ }
67
+ }
68
+ async function runRenders(baseUrl, renders) {
69
+ let chromium;
70
+ try {
71
+ ({ chromium } = await import("playwright"));
72
+ } catch {
73
+ return [
74
+ {
75
+ name: "playwright available",
76
+ pass: false,
77
+ detail: "playwright not installed \u2014 run `pnpm add playwright && pnpm exec playwright install chromium`"
78
+ }
79
+ ];
80
+ }
81
+ const base = baseUrl.replace(/\/+$/, "");
82
+ const checks = [];
83
+ let browser;
84
+ try {
85
+ browser = await chromium.launch();
86
+ } catch (e) {
87
+ return [
88
+ {
89
+ name: "launch browser",
90
+ pass: false,
91
+ detail: `${msg(e)} (try \`playwright install chromium\`)`
92
+ }
93
+ ];
94
+ }
95
+ try {
96
+ for (const path of renders) {
97
+ const page = await browser.newPage();
98
+ try {
99
+ const res = await page.goto(base + path, { waitUntil: "domcontentloaded" });
100
+ const ok = res?.ok() ?? false;
101
+ const aria = await page.locator("body").ariaSnapshot();
102
+ const nonEmpty = aria.trim().length > 0;
103
+ checks.push({
104
+ name: `renders ${path}`,
105
+ pass: ok && nonEmpty,
106
+ detail: !ok ? `status ${res?.status() ?? "none"}` : nonEmpty ? void 0 : "empty accessibility tree"
107
+ });
108
+ } catch (e) {
109
+ checks.push({ name: `renders ${path}`, pass: false, detail: msg(e) });
110
+ } finally {
111
+ await page.close();
112
+ }
113
+ }
114
+ } finally {
115
+ await browser.close();
116
+ }
117
+ return checks;
118
+ }
119
+
120
+ export {
121
+ verifyPlaywright
122
+ };
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-JRCATCRY.js";
6
- import "./chunk-ADS6BJJ5.js";
7
- import "./chunk-WFZTRXBF.js";
5
+ } from "./chunk-6USV5AQV.js";
6
+ import "./chunk-HX7VA25D.js";
7
+ import "./chunk-N3IKUCSF.js";
8
8
  import "./chunk-KP3Y6WRU.js";
9
9
  import "./chunk-UXHHLEYO.js";
10
10
  import "./chunk-6N7MD6FR.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  verifyMcp
3
- } from "./chunk-ADS6BJJ5.js";
3
+ } from "./chunk-HX7VA25D.js";
4
4
  import "./chunk-QFKE5JKC.js";
5
5
  export {
6
6
  verifyMcp
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  verifyPlaywright
3
- } from "./chunk-WFZTRXBF.js";
3
+ } from "./chunk-N3IKUCSF.js";
4
4
  import "./chunk-QFKE5JKC.js";
5
5
  export {
6
6
  verifyPlaywright
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,9 +32,9 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@rtrentjones/greenlight-adapters": "0.2.4",
35
- "@rtrentjones/greenlight-loop": "0.2.4",
35
+ "@rtrentjones/greenlight-shared": "0.2.4",
36
36
  "@rtrentjones/greenlight-verify": "0.2.4",
37
- "@rtrentjones/greenlight-shared": "0.2.4"
37
+ "@rtrentjones/greenlight-loop": "0.2.4"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",
@@ -1,61 +0,0 @@
1
- import {
2
- msg,
3
- report
4
- } from "./chunk-QFKE5JKC.js";
5
-
6
- // ../packages/verify/src/playwright.ts
7
- async function verifyPlaywright(baseUrl, spec) {
8
- let chromium;
9
- try {
10
- ({ chromium } = await import("playwright"));
11
- } catch {
12
- return report("playwright", baseUrl, [
13
- {
14
- name: "playwright available",
15
- pass: false,
16
- detail: "playwright not installed \u2014 run `pnpm add playwright && pnpm exec playwright install chromium`"
17
- }
18
- ]);
19
- }
20
- const base = baseUrl.replace(/\/+$/, "");
21
- const checks = [];
22
- let browser;
23
- try {
24
- browser = await chromium.launch();
25
- } catch (e) {
26
- return report("playwright", baseUrl, [
27
- {
28
- name: "launch browser",
29
- pass: false,
30
- detail: `${msg(e)} (try \`playwright install chromium\`)`
31
- }
32
- ]);
33
- }
34
- try {
35
- for (const path of spec.renders) {
36
- const page = await browser.newPage();
37
- try {
38
- const res = await page.goto(base + path, { waitUntil: "domcontentloaded" });
39
- const ok = res?.ok() ?? false;
40
- const aria = await page.locator("body").ariaSnapshot();
41
- const nonEmpty = aria.trim().length > 0;
42
- checks.push({
43
- name: `renders ${path}`,
44
- pass: ok && nonEmpty,
45
- detail: !ok ? `status ${res?.status() ?? "none"}` : nonEmpty ? void 0 : "empty accessibility tree"
46
- });
47
- } catch (e) {
48
- checks.push({ name: `renders ${path}`, pass: false, detail: msg(e) });
49
- } finally {
50
- await page.close();
51
- }
52
- }
53
- } finally {
54
- await browser.close();
55
- }
56
- return report("playwright", baseUrl, checks);
57
- }
58
-
59
- export {
60
- verifyPlaywright
61
- };