@rtrentjones/greenlight 0.2.5 → 0.2.6

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/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @rtrentjones/greenlight
2
+
3
+ The Greenlight CLI — setup and lifecycle for the [Greenlight](https://github.com/RTrentJones/greenlight)
4
+ harness. Greenlight is a **clone-and-own** baseline that turns a domain + API tokens into a live
5
+ personal site plus a self-verifying agentic deploy loop, with plug-and-play subdomain tools (web apps
6
+ or MCP servers). It is provider-agnostic and free-tier-first, and it **edits declarative
7
+ infrastructure-as-code — your CI/CD applies it**. It is not a hosted PaaS.
8
+
9
+ This is the **single published package**: the CLI, with the framework libraries
10
+ (`shared`/`verify`/`adapters`/`loop`) bundled in. The Terraform modules are distributed as git tags
11
+ (pinned in lockstep with this package's version); the skills ship as a Claude Code plugin.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pnpm add @rtrentjones/greenlight # or npm i / yarn add
17
+ ```
18
+
19
+ A personal site is a **thin consumer** that depends on this package and owns only its manifest
20
+ (`greenlight.config.ts`) + content. Update with `pnpm update @rtrentjones/greenlight` — no framework
21
+ code to merge.
22
+
23
+ Optional peer features lazy-load and degrade to a failing check if absent (never a crash):
24
+
25
+ ```bash
26
+ pnpm add -D playwright @anthropic-ai/sdk # only for verify modes agent-web / eval
27
+ ```
28
+
29
+ ## CLI
30
+
31
+ ```bash
32
+ greenlight <command>
33
+ ```
34
+
35
+ | Command | What it does |
36
+ |---|---|
37
+ | `init --domain <d>` | scaffold the manifest + secrets store |
38
+ | `add <name> --lane <l> --target <t> [--data --auth --envs]` | **infra editor**: manifest entry → emit `infra/<name>.tf` + gather/verify tokens + wire the kit (never applies) |
39
+ | `adopt <name> --repo <url\|path> --lane --target` | onboard an existing tool repo (submodule wrapper, or `--standalone`) |
40
+ | `secrets gather <name> [--repo o/r] [--oci-config <path>]` | guided, link-first token prompts straight to GitHub secrets (no disk, no logs) |
41
+ | `secrets sync [--repo o/r] [--env <env>]` | push `.greenlight/secrets.env` → GitHub Actions secrets |
42
+ | `agent sync` | materialize the agent loop kit (skill + `.mcp.json` + CLAUDE block) into a repo |
43
+ | `preview <name>` | build + serve locally + verify in one command |
44
+ | `verify <name> --env <beta\|prod>` (or `--url`) | run the shared verify harness |
45
+ | `promote <name>` | gated `develop → main` fast-forward (after beta verify) |
46
+ | `deploy <name>` | target deploy hook (e.g. OCI restart = re-pull) |
47
+ | `doctor` / `config` | health checks / load + validate + print the manifest |
48
+
49
+ ## The loop
50
+
51
+ ```
52
+ greenlight add notes --lane mcp --target oci # one entry → Terraform + tokens + kit
53
+ greenlight verify notes --env beta # api | mcp | playwright | test | agent-web | eval
54
+ greenlight promote notes # gated develop → main, after beta verify passes
55
+ ```
56
+
57
+ The `verify` gate is the same code CI **and** the agent run — so changes ship with objective
58
+ confidence, not vibes.
59
+
60
+ ## Programmatic API
61
+
62
+ Typed helpers for your `greenlight.config.ts` and `verify.config.ts`:
63
+
64
+ ```ts
65
+ import { defineConfig, defineVerify } from '@rtrentjones/greenlight';
66
+
67
+ export default defineConfig({
68
+ domain: 'you.dev',
69
+ tools: { notes: { lane: 'mcp', target: 'oci', data: 'none', auth: 'bearer' } },
70
+ });
71
+ ```
72
+
73
+ Also exported: `loadConfig`, and the `GreenlightConfig` / `VerifySpec` types.
74
+
75
+ ## Links
76
+
77
+ - **Repo + full docs:** <https://github.com/RTrentJones/greenlight>
78
+ - **Architecture:** [docs/architecture.md](https://github.com/RTrentJones/greenlight/blob/main/docs/architecture.md)
79
+ - **Spec:** [greenlight-v1.md](https://github.com/RTrentJones/greenlight/blob/main/greenlight-v1.md)
80
+
81
+ MIT
@@ -12,10 +12,12 @@ blog and throwaway MCP dev targets.
12
12
 
13
13
  ## Token — `CLOUDFLARE_API_TOKEN`
14
14
 
15
- One token, **two scopes** (the trap that took down a live apply):
15
+ One token, these scopes (a missing scope took down a live apply more than once):
16
16
  - **Account · Workers Scripts · Edit** — deploy the keepalive worker / workers-target tools.
17
17
  - **Zone · DNS · Edit** — the subdomain CNAMEs.
18
18
  - **Account · Account Settings · Read** — read account id.
19
+ - **Account · Cloudflare Tunnel · Edit** — only if a tool uses `target: oci` (the cloudflared
20
+ tunnel). Without it, the tunnel resource fails with **403 Forbidden** on `cfd_tunnel` at apply.
19
21
 
20
22
  Create at dash → My Profile → API Tokens → Custom Token. Store in `.greenlight/secrets.env`
21
23
  (gitignored) and push to GitHub Actions with `greenlight secrets sync`. `greenlight add`
@@ -18,6 +18,19 @@ workflows. The `develop → main` flow is standardized (PR → preview, `develop
18
18
  use a fine-grained **PAT** with the minimal scopes (e.g. `Secrets: write`, `Administration`
19
19
  for protection). Prefer **GitHub OIDC → cloud** over long-lived cloud tokens where supported.
20
20
 
21
+ ### Poly-repo deploy loop — the two option-B PATs
22
+
23
+ An adopted tool (submodule) and its wrapper hand off via two fine-grained PATs (`secrets gather`
24
+ pushes each to the right repo; see docs/provider-tokens.md):
25
+
26
+ - **`GREENLIGHT_DISPATCH_TOKEN`** — on the **tool** repo, scoped **Contents: write** on the
27
+ **wrapper** → the tool's build fires `repository_dispatch` so the wrapper deploys.
28
+ - **`GREENLIGHT_STATUS_TOKEN`** — on the **wrapper** repo, scoped **Commit statuses: write** on the
29
+ **tool** → the wrapper posts deploy/verify status back to the tool's commit.
30
+
31
+ Provider creds (OCI/Cloudflare/…) live **only in the wrapper**; the tool repo holds just the
32
+ dispatch PAT (its build pushes to GHCR with the built-in `github.token`).
33
+
21
34
  ## Secrets sync
22
35
 
23
36
  `greenlight secrets sync [--repo o/r] [--env <env>]` pushes `.greenlight/secrets.env` to the
@@ -37,9 +37,10 @@ The `oci` provider (auth below) is added to `infra/main.tf`.
37
37
  `greenlight secrets gather <tool> --repo <o/r>` pushes the OCI creds straight to GitHub secrets
38
38
  (hidden prompts, no disk/logs). **The only manual OCI inputs are the API-key auth values** —
39
39
  `TF_VAR_OCI_TENANCY_OCID`, `TF_VAR_OCI_USER_OCID`, `TF_VAR_OCI_FINGERPRINT`, `TF_VAR_OCI_PRIVATE_KEY`
40
- (PEM), `TF_VAR_OCI_REGION` — plus `OCI_CONTAINER_INSTANCE_OCID` (the Terraform output, set after the
41
- first apply, for deploy). `TF_VAR_OCI_COMPARTMENT_ID` is **optional** (blank the tenancy/root
42
- compartment). Auth is API-key request signingno bearer, so no fetch-verify.
40
+ (PEM), `TF_VAR_OCI_REGION`. `TF_VAR_OCI_COMPARTMENT_ID` is **optional** (blank the tenancy/root
41
+ compartment). Auth is API-key request signing no bearer, so no fetch-verify. The container
42
+ instance OCID is **not** a manual input the deploy workflow resolves it at deploy time from OCI
43
+ by the instance's display name (= the tool name), so it's abstracted from the developer.
43
44
 
44
45
  **The VCN, subnet, and availability domain are NOT manual** — they're Terraform: the `oci-network`
45
46
  module creates the VCN + a public (egress-only) subnet, and the container-instance module looks the
package/dist/bin.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  resolveUrl,
7
7
  verifyAll
8
- } from "./chunk-KFKYLGFX.js";
8
+ } from "./chunk-LM6M3DIV.js";
9
9
  import "./chunk-XBDQJVAX.js";
10
10
  import "./chunk-WFZTRXBF.js";
11
11
  import "./chunk-KP3Y6WRU.js";
@@ -92,7 +92,8 @@ function addTool(config, t) {
92
92
  envs: t.envs ?? ["beta", "prod"],
93
93
  ...t.dir !== void 0 ? { dir: t.dir } : {},
94
94
  ...t.adopted ? { adopted: true } : {},
95
- ...t.external ? { external: true } : {}
95
+ ...t.external ? { external: true } : {},
96
+ ...t.port !== void 0 ? { port: t.port } : {}
96
97
  }
97
98
  ]
98
99
  };
@@ -185,16 +186,17 @@ var PACKS = [
185
186
  always: true,
186
187
  // the zone/DNS provider + Workers (keepalive) for every Greenlight setup
187
188
  appliesTo: () => true,
188
- guide: "docs/provider-tokens.md \u2014 CLOUDFLARE_API_TOKEN (Workers Scripts:Edit + Zone DNS:Edit)",
189
+ guide: "docs/provider-tokens.md \u2014 CLOUDFLARE_API_TOKEN (Workers Scripts:Edit + Zone DNS:Edit + Cloudflare Tunnel:Edit for oci tools)",
189
190
  setupUrl: "https://dash.cloudflare.com/profile/api-tokens",
190
191
  tokens: [
191
192
  {
192
193
  envVar: "CLOUDFLARE_API_TOKEN",
193
- label: "API token \u2014 Workers Scripts:Edit + Zone DNS:Edit",
194
+ label: "API token \u2014 Workers Scripts:Edit + Zone DNS:Edit (+ Cloudflare Tunnel:Edit for oci)",
194
195
  scopes: [
195
196
  "Account \xB7 Workers Scripts \xB7 Edit",
196
197
  "Zone \xB7 DNS \xB7 Edit",
197
- "Account \xB7 Account Settings \xB7 Read"
198
+ "Account \xB7 Account Settings \xB7 Read",
199
+ "Account \xB7 Cloudflare Tunnel \xB7 Edit (only if a tool uses target: oci)"
198
200
  ],
199
201
  verify: async (t) => {
200
202
  const r = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
@@ -385,7 +387,7 @@ function tokensForTool(tool) {
385
387
  }
386
388
 
387
389
  // src/version.ts
388
- var MODULE_REF = "v0.2.5";
390
+ var MODULE_REF = "v0.2.6";
389
391
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
390
392
  function moduleSource(module, ref = MODULE_REF) {
391
393
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -395,6 +397,7 @@ function moduleSource(module, ref = MODULE_REF) {
395
397
  var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
396
398
  function emitToolTf(opts) {
397
399
  const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
400
+ const port = opts.port ?? 8e3;
398
401
  const slug = opts.slug ?? `OWNER/${name}`;
399
402
  const useSupabase = data === "supabase";
400
403
  const useVercel = target === "vercel";
@@ -467,7 +470,7 @@ variable "${name}_vercel_project_id" {
467
470
  }
468
471
  if (useOci) {
469
472
  blocks.push(`# OCI Container Instance (Always-Free Ampere A1) running the tool's GHCR image + a cloudflared
470
- # sidecar; the tunnel routes ${name}.${domain} \u2192 the container at localhost:8000. The tool's OWN
473
+ # sidecar; the tunnel routes ${name}.${domain} \u2192 the container at localhost:${port}. The tool's OWN
471
474
  # CI builds + pushes the image (provider-agnostic); deploy = restart the instance (re-pull).
472
475
  # beta would be a second instance + tunnel route \u2014 mind the free 2-OCPU / 12-GB A1 cap.
473
476
  module "${name}_tunnel" {
@@ -476,7 +479,7 @@ module "${name}_tunnel" {
476
479
  account_id = var.cloudflare_account_id
477
480
  name = "${name}-tunnel"
478
481
  ingress = [
479
- { hostname = "${name}.${domain}", service = "http://localhost:8000" },
482
+ { hostname = "${name}.${domain}", service = "http://localhost:${port}" },
480
483
  ]
481
484
  }
482
485
 
@@ -560,11 +563,13 @@ function emitWrapperMainTf(opts) {
560
563
  if (need.has("supabase")) providerBlocks.push('provider "supabase" {}');
561
564
  if (need.has("oci")) {
562
565
  providerBlocks.push(`provider "oci" {
563
- tenancy_ocid = var.oci_tenancy_ocid
564
- user_ocid = var.oci_user_ocid
565
- fingerprint = var.oci_fingerprint
566
+ # trimspace guards against a trailing newline/space in a pasted secret (a malformed region
567
+ # makes the identity endpoint hostname fail to resolve \u2014 "no such host" \u2014 at plan time).
568
+ tenancy_ocid = trimspace(var.oci_tenancy_ocid)
569
+ user_ocid = trimspace(var.oci_user_ocid)
570
+ fingerprint = trimspace(var.oci_fingerprint)
566
571
  private_key = var.oci_private_key
567
- region = var.oci_region
572
+ region = trimspace(var.oci_region)
568
573
  }`);
569
574
  }
570
575
  const vars = ['variable "cloudflare_zone_id" { type = string }'];
@@ -1039,7 +1044,7 @@ async function addCommand(args) {
1039
1044
  const name = args[0];
1040
1045
  if (!name || name.startsWith("-")) {
1041
1046
  throw new Error(
1042
- "usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod]"
1047
+ "usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod] [--port 8000]"
1043
1048
  );
1044
1049
  }
1045
1050
  const lane = flag2(args, "--lane");
@@ -1049,13 +1054,15 @@ async function addCommand(args) {
1049
1054
  if (path.endsWith(".example.ts")) {
1050
1055
  throw new Error("no greenlight.config.ts \u2014 run `greenlight init` first");
1051
1056
  }
1057
+ const portFlag = flag2(args, "--port");
1052
1058
  const next = addTool(config, {
1053
1059
  name,
1054
1060
  lane,
1055
1061
  target,
1056
1062
  data: flag2(args, "--data"),
1057
1063
  auth: flag2(args, "--auth"),
1058
- envs: flag2(args, "--envs")?.split(",")
1064
+ envs: flag2(args, "--envs")?.split(","),
1065
+ port: portFlag ? Number(portFlag) : void 0
1059
1066
  });
1060
1067
  const entry = next.tools.find((t) => t.name === name);
1061
1068
  const data = entry?.data ?? "none";
@@ -1094,7 +1101,10 @@ async function addCommand(args) {
1094
1101
  if (existsSync6(toolTf)) {
1095
1102
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1096
1103
  } else {
1097
- writeFileSync3(toolTf, emitToolTf({ name, domain: config.domain, lane, target, data, envs }));
1104
+ writeFileSync3(
1105
+ toolTf,
1106
+ emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
1107
+ );
1098
1108
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1099
1109
  }
1100
1110
  if (!args.includes("--no-tokens")) {
@@ -1356,7 +1366,7 @@ jobs:
1356
1366
  - uses: jdx/mise-action@v2
1357
1367
  - run: pnpm install --frozen-lockfile
1358
1368
  - run: pip install --quiet oci-cli
1359
- - name: Deploy (restart container instance -> re-pull GHCR image)
1369
+ - name: Deploy (resolve instance OCID by name -> restart -> re-pull GHCR image)
1360
1370
  env:
1361
1371
  # The OCI CLI reuses the SAME TF_VAR_OCI_* secrets the apply uses \u2014 one secret set.
1362
1372
  OCI_CLI_TENANCY: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
@@ -1364,8 +1374,31 @@ jobs:
1364
1374
  OCI_CLI_FINGERPRINT: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
1365
1375
  OCI_CLI_KEY_CONTENT: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
1366
1376
  OCI_CLI_REGION: \${{ secrets.TF_VAR_OCI_REGION }}
1367
- OCI_CONTAINER_INSTANCE_OCID: \${{ secrets.OCI_CONTAINER_INSTANCE_OCID }}
1368
- run: pnpm exec greenlight deploy ${name} --env prod
1377
+ OCI_COMPARTMENT_ID: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
1378
+ run: |
1379
+ # The instance OCID is abstracted from the developer: resolve it from OCI by the
1380
+ # instance's display name (= the tool name, set by the oci-container-instance module).
1381
+ # No manually-fetched/stored OCID secret. Compartment falls back to the tenancy (root).
1382
+ set -o pipefail
1383
+ COMPARTMENT="\${OCI_COMPARTMENT_ID:-$OCI_CLI_TENANCY}"
1384
+ echo "Resolving '${name}' (region=$OCI_CLI_REGION)\u2026"
1385
+ oci container-instances container-instance list \\
1386
+ --compartment-id "$COMPARTMENT" --display-name ${name} --all > list.json \\
1387
+ || { echo "::error::oci list failed (auth/region/compartment) \u2014 see output above"; exit 1; }
1388
+ # OCIDs are identifiers, not secrets (tenancy/compartment are masked by Actions).
1389
+ echo "Matches:"; jq -r '(.data.items // .data // [])[]? | " \\(.["lifecycle-state"] // "?") \\(.id)"' list.json
1390
+ OCID=$(jq -r '[(.data.items // .data // [])[]? | select((.["lifecycle-state"] // "")=="ACTIVE") | .id][0] // ""' list.json)
1391
+ if [ -z "$OCID" ]; then
1392
+ echo "::error::no ACTIVE container instance named '${name}' in compartment $COMPARTMENT"
1393
+ exit 1
1394
+ fi
1395
+ echo "Resolved ${name} instance: $OCID"
1396
+ OCI_CONTAINER_INSTANCE_OCID="$OCID" pnpm exec greenlight deploy ${name} --env prod
1397
+ - name: Verify prod (gate the signal on real health, not just the restart)
1398
+ # The deploy "succeeds" only if the NEW image is actually serving. verify has a built-in
1399
+ # readiness wait (re-pull + container start). A failure here fails the job \u2192 the status
1400
+ # posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
1401
+ run: pnpm exec greenlight verify ${name} --env prod
1369
1402
  - name: Report status back to ${toolRepo}
1370
1403
  if: \${{ always() && github.event.client_payload.sha != '' }}
1371
1404
  env:
@@ -1488,7 +1521,7 @@ Next:
1488
1521
  git commit && git push # CI (infra.yml) applies. Tool's CI builds; wrapper deploys.${target === "oci" ? `
1489
1522
  Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # TF_VAR_OCI_* + GREENLIGHT_STATUS_TOKEN
1490
1523
  greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
1491
- After apply, set OCI_CONTAINER_INSTANCE_OCID (the TF output) in the wrapper.` : ""}`);
1524
+ The instance OCID is auto-resolved by the deploy workflow (by display name) \u2014 nothing to set.` : ""}`);
1492
1525
  }
1493
1526
  async function adoptStandalone(ctx) {
1494
1527
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
@@ -1710,7 +1743,7 @@ async function deployCommand(args) {
1710
1743
  }
1711
1744
  const { config } = await loadManifest();
1712
1745
  const entry = resolveEntry(config, name);
1713
- if (entry.external) {
1746
+ if (entry.external && entry.target !== "oci") {
1714
1747
  throw new Error(`"${name}" is external (registry pointer) \u2014 deploy it from its own repo`);
1715
1748
  }
1716
1749
  const adapter = createAdapter(entry.target, { domain: config.domain, name: entry.name });
@@ -33,6 +33,10 @@ var ToolSchema = z.object({
33
33
  access: AccessEnum.default("public"),
34
34
  envs: z.array(EnvEnum).nonempty("a tool needs at least one env"),
35
35
  adopted: z.boolean().default(false),
36
+ // The port the container listens on (target: oci). The tunnel routes to localhost:<port>;
37
+ // defaults to 8000 (the mcp/FastMCP convention). Set it for a lane:docker tool on a different
38
+ // port so the oci modules stay generic. Ignored by non-oci targets.
39
+ port: z.number().int().positive().optional(),
36
40
  // Directory the tool builds/deploys from. Defaults to tools/<name>; a standalone
37
41
  // (poly-repo) tool sets '.' (the repo root).
38
42
  dir: z.string().optional(),
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-KFKYLGFX.js";
5
+ } from "./chunk-LM6M3DIV.js";
6
6
  import "./chunk-XBDQJVAX.js";
7
7
  import "./chunk-WFZTRXBF.js";
8
8
  import "./chunk-KP3Y6WRU.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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
36
  "@rtrentjones/greenlight-shared": "0.2.4",
36
- "@rtrentjones/greenlight-verify": "0.2.4",
37
- "@rtrentjones/greenlight-loop": "0.2.4"
37
+ "@rtrentjones/greenlight-verify": "0.2.4"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",