@rtrentjones/greenlight 0.2.15 → 0.2.17

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,8 +5,8 @@ import {
5
5
  loadConfig,
6
6
  resolveUrl,
7
7
  verifyAll
8
- } from "./chunk-AL6IKVZE.js";
9
- import "./chunk-ADS6BJJ5.js";
8
+ } from "./chunk-6USV5AQV.js";
9
+ import "./chunk-HX7VA25D.js";
10
10
  import "./chunk-N3IKUCSF.js";
11
11
  import "./chunk-KP3Y6WRU.js";
12
12
  import "./chunk-UXHHLEYO.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.15";
429
+ var MODULE_REF = "v0.2.17";
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,25 @@ 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 platformPreview = t.target === "vercel";
1968
+ const gateable = Boolean(t.preview) || builtIn || platformPreview;
1969
+ out.push({
1970
+ name: `${t.name}: local preview gate`,
1971
+ status: gateable ? "ok" : "warn",
1972
+ detail: platformPreview ? "vercel per-PR preview + deployment_status verify" : 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`
1973
+ });
1974
+ return out;
1975
+ }
1895
1976
  function runDoctor(config, root) {
1896
1977
  const checks = [];
1897
1978
  if (config.blog) checks.push(dirCheck("blog", join4(root, "apps/blog")));
@@ -1904,18 +1985,10 @@ function runDoctor(config, root) {
1904
1985
  mcp: t.lane === "mcp"
1905
1986
  });
1906
1987
  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
- });
1988
+ } else {
1989
+ checks.push(dirCheck(t.name, join4(root, t.dir ?? join4("tools", t.name))));
1918
1990
  }
1991
+ checks.push(...conformanceChecks(t, root));
1919
1992
  }
1920
1993
  const needsKeepalive = config.tools.filter((t) => t.data === "supabase" || t.target === "oci");
1921
1994
  checks.push({
@@ -2356,18 +2429,57 @@ async function waitForServer(url, timeoutMs = 3e4) {
2356
2429
  }
2357
2430
  return false;
2358
2431
  }
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`);
2432
+ async function loadSpecs(entry) {
2433
+ const loaded = (entry.external && entry.name ? await loadExternalVerifySpec(entry.name) : await loadVerifySpec(entry.dir)) ?? defaultSpec(entry.lane);
2434
+ return Array.isArray(loaded) ? loaded : [loaded];
2435
+ }
2436
+ async function verifyLocal(entry, url) {
2437
+ process.env.GREENLIGHT_PREVIEW = "1";
2438
+ process.env.GREENLIGHT_VERIFY_URL = url;
2439
+ const specs = await loadSpecs(entry);
2440
+ const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2441
+ const reports = await verifyAll(url, specs, { toolDir });
2442
+ for (const report of reports) printReport(report);
2443
+ return allPass(reports);
2444
+ }
2445
+ async function previewViaDescriptor(entry, name, portOverride) {
2446
+ const pv = entry.preview;
2447
+ const lane = servePlan(entry.lane);
2448
+ const port = portOverride ?? pv.port ?? entry.port ?? lane.port;
2449
+ const path = pv.path ?? lane.path;
2450
+ const url = `http://localhost:${port}${path}`;
2451
+ const toolDir = resolve10(process.cwd(), entry.dir ?? ".");
2452
+ console.log(`preview ${name}: ${pv.command} (\u2192 ${url})`);
2453
+ const child = spawn(pv.command, {
2454
+ cwd: toolDir,
2455
+ shell: true,
2456
+ stdio: "inherit",
2457
+ detached: true,
2458
+ env: { ...process.env, PORT: String(port), GREENLIGHT_PREVIEW: "1" }
2459
+ });
2460
+ try {
2461
+ if (!await waitForServer(url, 12e4)) {
2462
+ throw new Error(`preview server did not become reachable at ${url} (check: ${pv.command})`);
2463
+ }
2464
+ return await verifyLocal(entry, url);
2465
+ } finally {
2466
+ if (pv.teardown) {
2467
+ try {
2468
+ execFileSync4(pv.teardown, { cwd: toolDir, shell: true, stdio: "inherit" });
2469
+ } catch {
2470
+ }
2471
+ }
2472
+ if (child.pid) {
2473
+ try {
2474
+ process.kill(-child.pid, "SIGTERM");
2475
+ } catch {
2476
+ child.kill("SIGTERM");
2477
+ }
2478
+ }
2369
2479
  }
2370
- const plan = servePlan(entry.lane, portArg ? Number(portArg) : void 0);
2480
+ }
2481
+ async function previewViaBuiltIn(entry, name, portOverride) {
2482
+ const plan = servePlan(entry.lane, portOverride);
2371
2483
  if (plan.build) {
2372
2484
  console.log(`build ${name} (${entry.dir})`);
2373
2485
  execFileSync4("pnpm", ["-C", entry.dir, "run", "build"], { stdio: "inherit" });
@@ -2380,7 +2492,6 @@ async function previewCommand(args) {
2380
2492
  stdio: "ignore",
2381
2493
  detached: true
2382
2494
  });
2383
- let pass = false;
2384
2495
  try {
2385
2496
  const base = `http://localhost:${plan.port}`;
2386
2497
  if (!await waitForServer(base)) {
@@ -2388,12 +2499,7 @@ async function previewCommand(args) {
2388
2499
  `server did not start on :${plan.port} (check the tool's ${plan.script} script)`
2389
2500
  );
2390
2501
  }
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);
2502
+ return await verifyLocal(entry, base + plan.path);
2397
2503
  } finally {
2398
2504
  if (child.pid) {
2399
2505
  try {
@@ -2403,6 +2509,26 @@ async function previewCommand(args) {
2403
2509
  }
2404
2510
  }
2405
2511
  }
2512
+ }
2513
+ async function previewCommand(args) {
2514
+ const name = args[0];
2515
+ if (!name || name.startsWith("-")) {
2516
+ throw new Error("usage: greenlight preview <name> [--port <n>]");
2517
+ }
2518
+ const portArg = flag7(args, "--port");
2519
+ const port = portArg ? Number(portArg) : void 0;
2520
+ const { config } = await loadManifest();
2521
+ const entry = resolveEntry(config, name);
2522
+ let pass;
2523
+ if (entry.preview) {
2524
+ pass = await previewViaDescriptor(entry, name, port);
2525
+ } else if (entry.external) {
2526
+ throw new Error(
2527
+ `"${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`
2528
+ );
2529
+ } else {
2530
+ pass = await previewViaBuiltIn(entry, name, port);
2531
+ }
2406
2532
  process.exit(pass ? 0 : 1);
2407
2533
  }
2408
2534
 
@@ -2482,6 +2608,86 @@ async function promoteCommand(args) {
2482
2608
  process.exit(result.promoted ? 0 : 1);
2483
2609
  }
2484
2610
 
2611
+ // src/commands/status.ts
2612
+ import { execFileSync as execFileSync6 } from "child_process";
2613
+ function repoSlug(dir) {
2614
+ try {
2615
+ const url = execFileSync6("git", ["-C", dir, "remote", "get-url", "origin"], {
2616
+ encoding: "utf8"
2617
+ }).trim();
2618
+ const m = url.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
2619
+ return m?.[1] ?? null;
2620
+ } catch {
2621
+ return null;
2622
+ }
2623
+ }
2624
+ function workflowsFor(entry, name, wrapper, toolRepo) {
2625
+ if (!entry.external) {
2626
+ return [{ repo: wrapper, workflow: "deploy.yml", label: "deploy + verify" }];
2627
+ }
2628
+ if (entry.target === "oci") {
2629
+ return [
2630
+ {
2631
+ repo: toolRepo,
2632
+ workflow: "greenlight-build.yml",
2633
+ label: "build (test \u2192 image \u2192 dispatch)"
2634
+ },
2635
+ { repo: wrapper, workflow: `greenlight-deploy-${name}.yml`, label: "deploy + verify (prod)" },
2636
+ { repo: wrapper, workflow: `greenlight-remediate-${name}.yml`, label: "self-heal" }
2637
+ ];
2638
+ }
2639
+ if (entry.target === "vercel") {
2640
+ return [
2641
+ { repo: toolRepo, workflow: "greenlight-verify.yml", label: "verify (deployment_status)" }
2642
+ ];
2643
+ }
2644
+ return [{ repo: toolRepo, workflow: "deploy.yml", label: "deploy + verify" }];
2645
+ }
2646
+ function lastRun(repo, workflow) {
2647
+ try {
2648
+ const out = execFileSync6(
2649
+ "gh",
2650
+ [
2651
+ "run",
2652
+ "list",
2653
+ "--repo",
2654
+ repo,
2655
+ "--workflow",
2656
+ workflow,
2657
+ "--limit",
2658
+ "1",
2659
+ "--json",
2660
+ "status,conclusion,displayTitle,url"
2661
+ ],
2662
+ { encoding: "utf8" }
2663
+ );
2664
+ const runs = JSON.parse(out);
2665
+ const r = runs[0];
2666
+ if (!r) return "no runs";
2667
+ const state = r.status === "completed" ? r.conclusion : r.status;
2668
+ const icon = state === "success" ? "\u2714" : state === "failure" ? "\u2718" : "\xB7";
2669
+ return `${icon} ${state} ${r.displayTitle}
2670
+ ${r.url}`;
2671
+ } catch (e) {
2672
+ return `(gh unavailable \u2014 ${e instanceof Error ? e.message.split("\n")[0] : "error"})`;
2673
+ }
2674
+ }
2675
+ async function statusCommand(args) {
2676
+ const name = args[0];
2677
+ if (!name || name.startsWith("-")) throw new Error("usage: greenlight status <name>");
2678
+ const { config } = await loadManifest();
2679
+ const entry = resolveEntry(config, name);
2680
+ const wrapper = repoSlug(process.cwd()) ?? "(this repo)";
2681
+ const toolRepo = entry.external ? repoSlug(entry.dir) ?? "(tool repo)" : wrapper;
2682
+ console.log(`status: ${name} (${entry.lane}/${entry.target})
2683
+ `);
2684
+ for (const w of workflowsFor(entry, name, wrapper, toolRepo)) {
2685
+ console.log(` ${w.label} [${w.repo} \xB7 ${w.workflow}]`);
2686
+ console.log(` ${lastRun(w.repo, w.workflow)}
2687
+ `);
2688
+ }
2689
+ }
2690
+
2485
2691
  // src/bin.ts
2486
2692
  var HELP = `greenlight <command>
2487
2693
 
@@ -2492,6 +2698,7 @@ var HELP = `greenlight <command>
2492
2698
  preview <name> [--port <n>] build + serve locally + verify (one command)
2493
2699
  verify <name> [--env <env> | --url <url>] run the verify harness against the URL
2494
2700
  promote <name> [--perform] [--push] gated develop -> main fast-forward
2701
+ status <name> last ship/deploy/verify run for a tool (via gh)
2495
2702
  secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
2496
2703
  secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
2497
2704
  agent sync write the loop skill + CLAUDE.md block into this repo
@@ -2523,6 +2730,8 @@ async function main() {
2523
2730
  return verifyCommand(args);
2524
2731
  case "promote":
2525
2732
  return promoteCommand(args);
2733
+ case "status":
2734
+ return statusCommand(args);
2526
2735
  case "secrets":
2527
2736
  return secretsCommand(args);
2528
2737
  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,7 +243,7 @@ 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": {
@@ -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
  }
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-AL6IKVZE.js";
6
- import "./chunk-ADS6BJJ5.js";
5
+ } from "./chunk-6USV5AQV.js";
6
+ import "./chunk-HX7VA25D.js";
7
7
  import "./chunk-N3IKUCSF.js";
8
8
  import "./chunk-KP3Y6WRU.js";
9
9
  import "./chunk-UXHHLEYO.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "@anthropic-ai/sdk": "^0.69.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@rtrentjones/greenlight-shared": "0.2.4",
35
- "@rtrentjones/greenlight-loop": "0.2.4",
36
34
  "@rtrentjones/greenlight-adapters": "0.2.4",
35
+ "@rtrentjones/greenlight-loop": "0.2.4",
36
+ "@rtrentjones/greenlight-shared": "0.2.4",
37
37
  "@rtrentjones/greenlight-verify": "0.2.4"
38
38
  },
39
39
  "scripts": {