@rtrentjones/greenlight 0.2.4 → 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 +81 -0
- package/assets/skills/provider-cloudflare/SKILL.md +3 -1
- package/assets/skills/provider-github/SKILL.md +13 -0
- package/assets/skills/provider-oci/SKILL.md +26 -10
- package/dist/bin.js +278 -63
- package/dist/{chunk-KFKYLGFX.js → chunk-LM6M3DIV.js} +4 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
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,
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: provider-oci
|
|
3
|
-
description: How Oracle Cloud (OCI) works in a Greenlight setup — the `target: oci` runtime for stateful MCP servers (BAMCP) on a free-tier Ampere A1 Container Instance, the provider-agnostic build-via-GitHub→GHCR model, Greenlight-owned compute + tunnel Terraform, the OCI token CLI, deploy = restart, and the
|
|
3
|
+
description: How Oracle Cloud (OCI) works in a Greenlight setup — the `target: oci` runtime for stateful MCP servers (BAMCP) on a free-tier Ampere A1 Container Instance, the provider-agnostic build-via-GitHub→GHCR model, Greenlight-owned compute + tunnel Terraform, the OCI token CLI, deploy = restart, and staying on the free tier (no PAYG; recover-on-alert). Use when wiring/debugging an oci-target tool.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# provider-oci
|
|
@@ -34,11 +34,26 @@ The `oci` provider (auth below) is added to `infra/main.tf`.
|
|
|
34
34
|
|
|
35
35
|
## OCI token CLI
|
|
36
36
|
|
|
37
|
-
`greenlight
|
|
38
|
-
**
|
|
39
|
-
`
|
|
40
|
-
`
|
|
41
|
-
|
|
37
|
+
`greenlight secrets gather <tool> --repo <o/r>` pushes the OCI creds straight to GitHub secrets
|
|
38
|
+
(hidden prompts, no disk/logs). **The only manual OCI inputs are the API-key auth values** —
|
|
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`. `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.
|
|
44
|
+
|
|
45
|
+
**The VCN, subnet, and availability domain are NOT manual** — they're Terraform: the `oci-network`
|
|
46
|
+
module creates the VCN + a public (egress-only) subnet, and the container-instance module looks the
|
|
47
|
+
AD up via an `oci_identity_availability_domains` data source. So the bootstrap is just "create one
|
|
48
|
+
API key" — Terraform can't create the credential it needs to authenticate, but it owns everything
|
|
49
|
+
after that. (Out-of-A1-capacity in one AD? set `availability_domain` on the instance module to pin
|
|
50
|
+
another — the only time you touch it.)
|
|
51
|
+
|
|
52
|
+
**Shortcut — feed the API-key config preview directly.** After *Add API key*, OCI shows a
|
|
53
|
+
"Configuration file preview" (the `[DEFAULT]` block) and you download the `.pem`. Pass both:
|
|
54
|
+
`greenlight secrets gather <tool> --repo <o/r> --oci-config ~/path/config [--oci-key ~/path/key.pem]`
|
|
55
|
+
— it auto-fills the 5 auth secrets (incl. the multi-line PEM, read from the file so it's never
|
|
56
|
+
pasted) and only prompts for the 3 placement values + the option-B deploy PATs.
|
|
42
57
|
|
|
43
58
|
## Deploy = restart (re-pull)
|
|
44
59
|
|
|
@@ -46,11 +61,12 @@ The `oci` provider (auth below) is added to `infra/main.tf`.
|
|
|
46
61
|
--container-instance-id <OCID>` — the instance re-pulls the latest GHCR image. The tool's CI
|
|
47
62
|
builds; an event trigger (the chosen deploy option) fires the restart. The adapter does NOT build.
|
|
48
63
|
|
|
49
|
-
##
|
|
64
|
+
## Idle-reclaim — stay free, recover on alert
|
|
50
65
|
|
|
51
|
-
OCI
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
OCI Always-Free can reclaim idle compute. We **stay on the free tier** and accept that: the
|
|
67
|
+
instance runs restart-policy ALWAYS, keepalive health-checks it, and if it's ever stopped/reclaimed
|
|
68
|
+
the alert fires and a **re-apply / redeploy** restores it. **PAYG is NOT used** — it's an optional
|
|
69
|
+
last resort (see `docs/oci-payg-runbook.md`) only if reclaim ever becomes a recurring problem.
|
|
54
70
|
|
|
55
71
|
## Verify
|
|
56
72
|
The tool is typically an **MCP server**: verify with `mode: mcp`, connect at `<name>.<domain>/mcp`
|
package/dist/bin.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
loadConfig,
|
|
6
6
|
resolveUrl,
|
|
7
7
|
verifyAll
|
|
8
|
-
} from "./chunk-
|
|
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
|
};
|
|
@@ -132,6 +133,7 @@ function resolveEntry(config, name) {
|
|
|
132
133
|
name: void 0,
|
|
133
134
|
lane: config.blog.lane,
|
|
134
135
|
target: config.blog.target,
|
|
136
|
+
data: config.blog.data,
|
|
135
137
|
dir: "apps/blog",
|
|
136
138
|
external: false
|
|
137
139
|
};
|
|
@@ -145,6 +147,7 @@ function resolveEntry(config, name) {
|
|
|
145
147
|
name: tool.name,
|
|
146
148
|
lane: tool.lane,
|
|
147
149
|
target: tool.target,
|
|
150
|
+
data: tool.data,
|
|
148
151
|
dir: tool.dir ?? `tools/${tool.name}`,
|
|
149
152
|
external: tool.external
|
|
150
153
|
};
|
|
@@ -183,15 +186,17 @@ var PACKS = [
|
|
|
183
186
|
always: true,
|
|
184
187
|
// the zone/DNS provider + Workers (keepalive) for every Greenlight setup
|
|
185
188
|
appliesTo: () => true,
|
|
186
|
-
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)",
|
|
190
|
+
setupUrl: "https://dash.cloudflare.com/profile/api-tokens",
|
|
187
191
|
tokens: [
|
|
188
192
|
{
|
|
189
193
|
envVar: "CLOUDFLARE_API_TOKEN",
|
|
190
|
-
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)",
|
|
191
195
|
scopes: [
|
|
192
196
|
"Account \xB7 Workers Scripts \xB7 Edit",
|
|
193
197
|
"Zone \xB7 DNS \xB7 Edit",
|
|
194
|
-
"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)"
|
|
195
200
|
],
|
|
196
201
|
verify: async (t) => {
|
|
197
202
|
const r = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
@@ -214,6 +219,7 @@ var PACKS = [
|
|
|
214
219
|
name: "Vercel",
|
|
215
220
|
appliesTo: (t) => t.target === "vercel",
|
|
216
221
|
guide: "docs/provider-tokens.md \u2014 VERCEL_API_TOKEN (team-scoped)",
|
|
222
|
+
setupUrl: "https://vercel.com/account/settings/tokens",
|
|
217
223
|
tokens: [
|
|
218
224
|
{
|
|
219
225
|
envVar: "VERCEL_API_TOKEN",
|
|
@@ -235,6 +241,7 @@ var PACKS = [
|
|
|
235
241
|
name: "Supabase",
|
|
236
242
|
appliesTo: (t) => t.data === "supabase",
|
|
237
243
|
guide: "docs/provider-tokens.md \u2014 SUPABASE_ACCESS_TOKEN (Management API)",
|
|
244
|
+
setupUrl: "https://supabase.com/dashboard/account/tokens",
|
|
238
245
|
tokens: [
|
|
239
246
|
{
|
|
240
247
|
envVar: "SUPABASE_ACCESS_TOKEN",
|
|
@@ -269,6 +276,7 @@ var PACKS = [
|
|
|
269
276
|
// remote state backs every wrapper's infra
|
|
270
277
|
appliesTo: () => true,
|
|
271
278
|
guide: "docs/terraform-state-r2.md \u2014 HCP Terraform free tier (no credit card)",
|
|
279
|
+
setupUrl: "https://app.terraform.io/app/settings/tokens",
|
|
272
280
|
tokens: [
|
|
273
281
|
{
|
|
274
282
|
envVar: "TF_API_TOKEN",
|
|
@@ -290,6 +298,7 @@ var PACKS = [
|
|
|
290
298
|
// secrets sync + repo/branch infra
|
|
291
299
|
appliesTo: () => true,
|
|
292
300
|
guide: "docs/provider-tokens.md \u2014 GitHub (gh auth, or a fine-grained PAT)",
|
|
301
|
+
setupUrl: "https://github.com/settings/personal-access-tokens/new",
|
|
293
302
|
tokens: [
|
|
294
303
|
{
|
|
295
304
|
envVar: "GITHUB_TOKEN",
|
|
@@ -305,7 +314,8 @@ var PACKS = [
|
|
|
305
314
|
id: "oci",
|
|
306
315
|
name: "Oracle Cloud (OCI)",
|
|
307
316
|
appliesTo: (t) => t.target === "oci",
|
|
308
|
-
guide: "docs/oci-payg-runbook.md \u2014 Always-Free A1 Container Instance + tunnel (PAYG
|
|
317
|
+
guide: "docs/oci-payg-runbook.md \u2014 Always-Free A1 Container Instance + tunnel (no PAYG)",
|
|
318
|
+
setupUrl: "https://cloud.oracle.com \u2014 Profile \u2192 User settings \u2192 Tokens and keys \u2192 Add API key",
|
|
309
319
|
tokens: [
|
|
310
320
|
// OCI provider auth = API-key request signing (no bearer → no cheap fetch verify). These
|
|
311
321
|
// flow to the `oci` Terraform provider as TF_VAR_oci_* (the wrapper apply uses them).
|
|
@@ -318,16 +328,12 @@ var PACKS = [
|
|
|
318
328
|
optional: true
|
|
319
329
|
},
|
|
320
330
|
{ envVar: "TF_VAR_oci_region", label: "OCI region, e.g. us-ashburn-1", optional: true },
|
|
321
|
-
//
|
|
322
|
-
|
|
331
|
+
// Compartment is the ONLY placement input, and it's optional — blank → the tenancy (root)
|
|
332
|
+
// compartment. The VCN/subnet are created by the oci-network module and the availability
|
|
333
|
+
// domain is looked up by a data source, so neither is a manual secret anymore.
|
|
323
334
|
{
|
|
324
|
-
envVar: "
|
|
325
|
-
label: "OCI
|
|
326
|
-
optional: true
|
|
327
|
-
},
|
|
328
|
-
{
|
|
329
|
-
envVar: "TF_VAR_oci_subnet_id",
|
|
330
|
-
label: "OCI subnet OCID (public, for egress)",
|
|
335
|
+
envVar: "TF_VAR_oci_compartment_id",
|
|
336
|
+
label: "OCI compartment OCID (optional \u2014 blank uses the tenancy/root compartment)",
|
|
331
337
|
optional: true
|
|
332
338
|
},
|
|
333
339
|
// Deploy (restart the instance → re-pull). Set from the Terraform output.
|
|
@@ -335,11 +341,25 @@ var PACKS = [
|
|
|
335
341
|
envVar: "OCI_CONTAINER_INSTANCE_OCID",
|
|
336
342
|
label: "container instance OCID (TF output) \u2014 `greenlight deploy` restarts it",
|
|
337
343
|
optional: true
|
|
344
|
+
},
|
|
345
|
+
// Option-B event-driven deploy (GitHub PATs). dispatch → set on the TOOL repo;
|
|
346
|
+
// status → set on the WRAPPER repo. Skip the one that doesn't match `--repo`.
|
|
347
|
+
{
|
|
348
|
+
envVar: "GREENLIGHT_DISPATCH_TOKEN",
|
|
349
|
+
label: "GitHub PAT, Contents:write on the WRAPPER (TOOL repo fires the deploy dispatch)",
|
|
350
|
+
optional: true,
|
|
351
|
+
setupUrl: "https://github.com/settings/personal-access-tokens/new"
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
envVar: "GREENLIGHT_STATUS_TOKEN",
|
|
355
|
+
label: "GitHub PAT, Commits:write on the TOOL (WRAPPER posts deploy status back)",
|
|
356
|
+
optional: true,
|
|
357
|
+
setupUrl: "https://github.com/settings/personal-access-tokens/new"
|
|
338
358
|
}
|
|
339
359
|
],
|
|
340
360
|
skill: "provider-oci",
|
|
341
|
-
|
|
342
|
-
|
|
361
|
+
// DNS + tunnel + network (VCN/subnet) + compute; deploy = restart.
|
|
362
|
+
tfModules: ["tool", "tunnel", "oci-network", "oci-container-instance"]
|
|
343
363
|
}
|
|
344
364
|
];
|
|
345
365
|
function packsForTool(tool) {
|
|
@@ -367,7 +387,7 @@ function tokensForTool(tool) {
|
|
|
367
387
|
}
|
|
368
388
|
|
|
369
389
|
// src/version.ts
|
|
370
|
-
var MODULE_REF = "v0.2.
|
|
390
|
+
var MODULE_REF = "v0.2.6";
|
|
371
391
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
372
392
|
function moduleSource(module, ref = MODULE_REF) {
|
|
373
393
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -377,6 +397,7 @@ function moduleSource(module, ref = MODULE_REF) {
|
|
|
377
397
|
var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
378
398
|
function emitToolTf(opts) {
|
|
379
399
|
const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
|
|
400
|
+
const port = opts.port ?? 8e3;
|
|
380
401
|
const slug = opts.slug ?? `OWNER/${name}`;
|
|
381
402
|
const useSupabase = data === "supabase";
|
|
382
403
|
const useVercel = target === "vercel";
|
|
@@ -384,13 +405,7 @@ function emitToolTf(opts) {
|
|
|
384
405
|
const envList = envs.map((e) => `"${e}"`).join(", ");
|
|
385
406
|
const blocks = [];
|
|
386
407
|
const assumes = ["var.cloudflare_zone_id"];
|
|
387
|
-
if (useOci)
|
|
388
|
-
assumes.push(
|
|
389
|
-
"var.cloudflare_account_id",
|
|
390
|
-
"var.oci_compartment_id",
|
|
391
|
-
"var.oci_availability_domain",
|
|
392
|
-
"var.oci_subnet_id"
|
|
393
|
-
);
|
|
408
|
+
if (useOci) assumes.push("var.cloudflare_account_id", "local.oci_compartment_id");
|
|
394
409
|
if (useSupabase) assumes.push("var.supabase_organization_id", "var.supabase_database_password");
|
|
395
410
|
const ghcrOwner = (slug.split("/")[0] ?? "owner").toLowerCase();
|
|
396
411
|
blocks.push(
|
|
@@ -455,7 +470,7 @@ variable "${name}_vercel_project_id" {
|
|
|
455
470
|
}
|
|
456
471
|
if (useOci) {
|
|
457
472
|
blocks.push(`# OCI Container Instance (Always-Free Ampere A1) running the tool's GHCR image + a cloudflared
|
|
458
|
-
# sidecar; the tunnel routes ${name}.${domain} \u2192 the container at localhost
|
|
473
|
+
# sidecar; the tunnel routes ${name}.${domain} \u2192 the container at localhost:${port}. The tool's OWN
|
|
459
474
|
# CI builds + pushes the image (provider-agnostic); deploy = restart the instance (re-pull).
|
|
460
475
|
# beta would be a second instance + tunnel route \u2014 mind the free 2-OCPU / 12-GB A1 cap.
|
|
461
476
|
module "${name}_tunnel" {
|
|
@@ -464,19 +479,27 @@ module "${name}_tunnel" {
|
|
|
464
479
|
account_id = var.cloudflare_account_id
|
|
465
480
|
name = "${name}-tunnel"
|
|
466
481
|
ingress = [
|
|
467
|
-
{ hostname = "${name}.${domain}", service = "http://localhost
|
|
482
|
+
{ hostname = "${name}.${domain}", service = "http://localhost:${port}" },
|
|
468
483
|
]
|
|
469
484
|
}
|
|
470
485
|
|
|
486
|
+
# Network is IaC too \u2014 VCN + public subnet (egress only). No hand-clicking in the OCI console.
|
|
487
|
+
module "${name}_network" {
|
|
488
|
+
source = "${moduleSource("oci-network", ref)}"
|
|
489
|
+
|
|
490
|
+
name = "${name}"
|
|
491
|
+
compartment_id = local.oci_compartment_id
|
|
492
|
+
}
|
|
493
|
+
|
|
471
494
|
module "${name}_instance" {
|
|
472
495
|
source = "${moduleSource("oci-container-instance", ref)}"
|
|
473
496
|
|
|
474
|
-
name
|
|
475
|
-
compartment_id
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
497
|
+
name = "${name}"
|
|
498
|
+
compartment_id = local.oci_compartment_id
|
|
499
|
+
subnet_id = module.${name}_network.subnet_id
|
|
500
|
+
image_url = var.${name}_image
|
|
501
|
+
tunnel_token = module.${name}_tunnel.token
|
|
502
|
+
# availability_domain is auto-picked (first AD in the compartment); set it to pin a specific AD.
|
|
480
503
|
|
|
481
504
|
# Tool runtime env \u2014 fill in (e.g. PORT/listen settings, auth). The container must listen on 8000.
|
|
482
505
|
environment = {}
|
|
@@ -540,11 +563,13 @@ function emitWrapperMainTf(opts) {
|
|
|
540
563
|
if (need.has("supabase")) providerBlocks.push('provider "supabase" {}');
|
|
541
564
|
if (need.has("oci")) {
|
|
542
565
|
providerBlocks.push(`provider "oci" {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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)
|
|
546
571
|
private_key = var.oci_private_key
|
|
547
|
-
region = var.oci_region
|
|
572
|
+
region = trimspace(var.oci_region)
|
|
548
573
|
}`);
|
|
549
574
|
}
|
|
550
575
|
const vars = ['variable "cloudflare_zone_id" { type = string }'];
|
|
@@ -561,10 +586,16 @@ function emitWrapperMainTf(opts) {
|
|
|
561
586
|
vars.push('variable "oci_fingerprint" { type = string }');
|
|
562
587
|
vars.push('variable "oci_private_key" {\n type = string\n sensitive = true\n}');
|
|
563
588
|
vars.push('variable "oci_region" { type = string }');
|
|
564
|
-
vars.push(
|
|
565
|
-
|
|
566
|
-
|
|
589
|
+
vars.push(
|
|
590
|
+
'variable "oci_compartment_id" {\n type = string\n default = "" # blank \u2192 tenancy (root) compartment\n}'
|
|
591
|
+
);
|
|
567
592
|
}
|
|
593
|
+
const localsBlock = need.has("oci") ? `
|
|
594
|
+
locals {
|
|
595
|
+
# Compartment for all OCI tools \u2014 blank var.oci_compartment_id falls back to the tenancy (root).
|
|
596
|
+
oci_compartment_id = var.oci_compartment_id != "" ? var.oci_compartment_id : var.oci_tenancy_ocid
|
|
597
|
+
}
|
|
598
|
+
` : "";
|
|
568
599
|
return `# Wrapper infra (singleton): providers + remote-state backend + shared variables.
|
|
569
600
|
# \`greenlight add\` appends per-tool module blocks as infra/<name>.tf. Apply is CI/CD's job
|
|
570
601
|
# (infra.yml). Fill in the HCP backend below before the first apply (docs/terraform-state-r2.md).
|
|
@@ -585,7 +616,7 @@ ${req.join("\n")}
|
|
|
585
616
|
${providerBlocks.join("\n")}
|
|
586
617
|
|
|
587
618
|
${vars.join("\n")}
|
|
588
|
-
`;
|
|
619
|
+
${localsBlock}`;
|
|
589
620
|
}
|
|
590
621
|
function providersForTool(tool) {
|
|
591
622
|
const ids = new Set(packsForTool(tool).map((p) => p.id));
|
|
@@ -599,12 +630,13 @@ function providersForTool(tool) {
|
|
|
599
630
|
// src/tokens.ts
|
|
600
631
|
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
601
632
|
import { resolve as resolve4 } from "path";
|
|
602
|
-
import { createInterface } from "readline/promises";
|
|
633
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
603
634
|
|
|
604
635
|
// src/commands/secrets.ts
|
|
605
636
|
import { execFileSync } from "child_process";
|
|
606
637
|
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
607
638
|
import { resolve as resolve3 } from "path";
|
|
639
|
+
import { createInterface } from "readline";
|
|
608
640
|
function parseSecretsEnv(text) {
|
|
609
641
|
const out = [];
|
|
610
642
|
for (const raw of text.split("\n")) {
|
|
@@ -616,6 +648,36 @@ function parseSecretsEnv(text) {
|
|
|
616
648
|
}
|
|
617
649
|
return out;
|
|
618
650
|
}
|
|
651
|
+
function parseOciConfig(text) {
|
|
652
|
+
const out = {};
|
|
653
|
+
for (const raw of text.split("\n")) {
|
|
654
|
+
const line = raw.trim();
|
|
655
|
+
if (line === "" || line.startsWith("#") || line.startsWith("[")) continue;
|
|
656
|
+
const eq = line.indexOf("=");
|
|
657
|
+
if (eq <= 0) continue;
|
|
658
|
+
const key = line.slice(0, eq).trim().toLowerCase();
|
|
659
|
+
if (!(key in out)) out[key] = line.slice(eq + 1).trim();
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
function ociPrefill(configPath, keyPath) {
|
|
664
|
+
const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
|
|
665
|
+
const map = /* @__PURE__ */ new Map();
|
|
666
|
+
const set = (k, v) => {
|
|
667
|
+
if (v) map.set(k, v);
|
|
668
|
+
};
|
|
669
|
+
set("TF_VAR_OCI_USER_OCID", cfg.user);
|
|
670
|
+
set("TF_VAR_OCI_FINGERPRINT", cfg.fingerprint);
|
|
671
|
+
set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
|
|
672
|
+
set("TF_VAR_OCI_REGION", cfg.region);
|
|
673
|
+
const pem = keyPath ?? cfg.key_file;
|
|
674
|
+
if (pem && existsSync3(pem)) {
|
|
675
|
+
map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
|
|
676
|
+
} else if (pem) {
|
|
677
|
+
console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
|
|
678
|
+
}
|
|
679
|
+
return map;
|
|
680
|
+
}
|
|
619
681
|
function parseRepo(remoteUrl) {
|
|
620
682
|
const m = remoteUrl.trim().match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/);
|
|
621
683
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
@@ -669,12 +731,135 @@ function syncSecrets(opts) {
|
|
|
669
731
|
}
|
|
670
732
|
return { repo, count: entries.length };
|
|
671
733
|
}
|
|
734
|
+
function hiddenPrompter() {
|
|
735
|
+
const tty = Boolean(process.stdin.isTTY);
|
|
736
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: tty });
|
|
737
|
+
if (tty) rl._writeToOutput = () => {
|
|
738
|
+
};
|
|
739
|
+
return {
|
|
740
|
+
ask: (query) => new Promise((resolve11) => {
|
|
741
|
+
process.stdout.write(query);
|
|
742
|
+
rl.question("", (val) => {
|
|
743
|
+
process.stdout.write("\n");
|
|
744
|
+
resolve11(val.trim());
|
|
745
|
+
});
|
|
746
|
+
}),
|
|
747
|
+
close: () => rl.close()
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function listGitHubSecrets(repo, env) {
|
|
751
|
+
const ghArgs = ["secret", "list", "--repo", repo, "--json", "name"];
|
|
752
|
+
if (env) ghArgs.push("--env", env);
|
|
753
|
+
try {
|
|
754
|
+
const out = execFileSync("gh", ghArgs, {
|
|
755
|
+
encoding: "utf8",
|
|
756
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
757
|
+
// don't leak gh's stderr into the guided flow
|
|
758
|
+
});
|
|
759
|
+
const parsed = JSON.parse(out);
|
|
760
|
+
return new Set(parsed.map((s) => s.name));
|
|
761
|
+
} catch {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function setGitHubSecret(repo, env, key, value) {
|
|
766
|
+
const ghArgs = ["secret", "set", key, "--repo", repo];
|
|
767
|
+
if (env) ghArgs.push("--env", env);
|
|
768
|
+
try {
|
|
769
|
+
execFileSync("gh", ghArgs, { input: value });
|
|
770
|
+
} catch (e) {
|
|
771
|
+
const err = e;
|
|
772
|
+
if (err.code === "ENOENT") {
|
|
773
|
+
throw new Error("the GitHub CLI `gh` is required \u2014 install it and run `gh auth login`");
|
|
774
|
+
}
|
|
775
|
+
const detail = err.stderr?.toString().trim();
|
|
776
|
+
throw new Error(`failed to set ${key}${detail ? `: ${detail}` : " (check `gh auth status`)"}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async function gatherSecrets(name, repo, env, prefill) {
|
|
780
|
+
const { config } = await loadManifest();
|
|
781
|
+
const entry = resolveEntry(config, name);
|
|
782
|
+
const packs = packsForTool({ target: entry.target, data: entry.data });
|
|
783
|
+
const dest = env ? `env "${env}" of ${repo}` : repo;
|
|
784
|
+
const existing = listGitHubSecrets(repo, env);
|
|
785
|
+
console.log(`Gathering secrets for "${name}" \u2192 GitHub ${dest}`);
|
|
786
|
+
console.log(
|
|
787
|
+
"Paste each value (hidden); Enter to skip. Values go straight to GitHub \u2014 never to disk."
|
|
788
|
+
);
|
|
789
|
+
console.log(
|
|
790
|
+
`[already set] = a value exists (paste to override, Enter to keep) \xB7 [not set] = new.${existing ? "" : " (could not read existing secrets \u2014 annotations omitted)"}`
|
|
791
|
+
);
|
|
792
|
+
if (prefill?.size) console.log(`Auto-filling ${prefill.size} value(s) from the OCI config.`);
|
|
793
|
+
console.log("");
|
|
794
|
+
const prompt = hiddenPrompter();
|
|
795
|
+
let pushed = 0;
|
|
796
|
+
try {
|
|
797
|
+
for (const pack of packs) {
|
|
798
|
+
console.log(`\u2500\u2500 ${pack.name}${pack.setupUrl ? ` \u2192 ${pack.setupUrl}` : ""}`);
|
|
799
|
+
for (const tok of pack.tokens) {
|
|
800
|
+
const key = tok.envVar.toUpperCase();
|
|
801
|
+
if (key === "GITHUB_TOKEN") {
|
|
802
|
+
console.log(" \xB7 GITHUB_TOKEN \u2014 provided automatically by Actions; skipping");
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const pre = prefill?.get(key);
|
|
806
|
+
if (pre) {
|
|
807
|
+
setGitHubSecret(repo, env, key, pre);
|
|
808
|
+
console.log(` \u2714 ${existing?.has(key) ? "overrode" : "pushed"} ${key} \u2190 OCI config`);
|
|
809
|
+
pushed++;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (tok.scopes?.length) console.log(` scopes: ${tok.scopes.join(", ")}`);
|
|
813
|
+
if (tok.setupUrl) console.log(` link: ${tok.setupUrl}`);
|
|
814
|
+
const state = existing ? existing.has(key) ? " [already set]" : " [not set]" : "";
|
|
815
|
+
const value = await prompt.ask(` ${key} \u2014 ${tok.label}${state}
|
|
816
|
+
value: `);
|
|
817
|
+
if (!value) {
|
|
818
|
+
console.log(existing?.has(key) ? " \xB7 kept existing" : " \xB7 skipped");
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (tok.verify) {
|
|
822
|
+
const check = await tok.verify(value, {}).catch((e) => ({ ok: false, detail: e instanceof Error ? e.message : String(e) }));
|
|
823
|
+
if (!check.ok) {
|
|
824
|
+
console.log(
|
|
825
|
+
` \u2716 verify failed${check.detail ? ` (${check.detail})` : ""} \u2014 not pushed`
|
|
826
|
+
);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
console.log(" \u2714 verified");
|
|
830
|
+
}
|
|
831
|
+
setGitHubSecret(repo, env, key, value);
|
|
832
|
+
const verb = existing?.has(key) ? "overrode" : "pushed";
|
|
833
|
+
console.log(` \u2714 ${verb} ${key} \u2192 ${repo}`);
|
|
834
|
+
pushed++;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
} finally {
|
|
838
|
+
prompt.close();
|
|
839
|
+
}
|
|
840
|
+
console.log(`
|
|
841
|
+
${pushed} secret(s) pushed to ${repo}. (None written to disk.)`);
|
|
842
|
+
}
|
|
672
843
|
async function secretsCommand(args) {
|
|
673
|
-
|
|
844
|
+
const sub = args[0];
|
|
845
|
+
if (sub === "gather") {
|
|
846
|
+
const name = args[1];
|
|
847
|
+
if (!name || name.startsWith("-")) {
|
|
848
|
+
throw new Error("usage: greenlight secrets gather <name> [--repo owner/repo] [--env <env>]");
|
|
849
|
+
}
|
|
850
|
+
const repo = flag(args, "--repo") ?? detectRepo(process.cwd());
|
|
851
|
+
if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
|
|
852
|
+
const ociConfig2 = flag(args, "--oci-config");
|
|
853
|
+
const ociKey = flag(args, "--oci-key");
|
|
854
|
+
const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
|
|
855
|
+
await gatherSecrets(name, repo, flag(args, "--env"), prefill);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (sub !== "sync") {
|
|
674
859
|
console.log(
|
|
675
|
-
"usage
|
|
860
|
+
"usage:\n greenlight secrets sync [--repo owner/repo] [--env <env>] # push .greenlight/secrets.env\n greenlight secrets gather <name> [--repo owner/repo] [--env <env>] # guided, link-first, straight to GitHub (no disk/logs)\n [--oci-config <path>] [--oci-key <path>] # auto-fill OCI auth from the API-key config preview + .pem"
|
|
676
861
|
);
|
|
677
|
-
process.exit(
|
|
862
|
+
process.exit(sub ? 1 : 0);
|
|
678
863
|
}
|
|
679
864
|
const { count } = syncSecrets({
|
|
680
865
|
cwd: process.cwd(),
|
|
@@ -724,7 +909,7 @@ async function ensureTokensForTool(cwd, tool, opts = {}) {
|
|
|
724
909
|
const interactive = Boolean(process.stdin.isTTY);
|
|
725
910
|
const env = presentEnv(cwd);
|
|
726
911
|
const results = [];
|
|
727
|
-
const rl = interactive ?
|
|
912
|
+
const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
|
|
728
913
|
try {
|
|
729
914
|
for (const spec of tokensForTool(tool)) {
|
|
730
915
|
let value = env[spec.envVar];
|
|
@@ -859,7 +1044,7 @@ async function addCommand(args) {
|
|
|
859
1044
|
const name = args[0];
|
|
860
1045
|
if (!name || name.startsWith("-")) {
|
|
861
1046
|
throw new Error(
|
|
862
|
-
"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]"
|
|
863
1048
|
);
|
|
864
1049
|
}
|
|
865
1050
|
const lane = flag2(args, "--lane");
|
|
@@ -869,13 +1054,15 @@ async function addCommand(args) {
|
|
|
869
1054
|
if (path.endsWith(".example.ts")) {
|
|
870
1055
|
throw new Error("no greenlight.config.ts \u2014 run `greenlight init` first");
|
|
871
1056
|
}
|
|
1057
|
+
const portFlag = flag2(args, "--port");
|
|
872
1058
|
const next = addTool(config, {
|
|
873
1059
|
name,
|
|
874
1060
|
lane,
|
|
875
1061
|
target,
|
|
876
1062
|
data: flag2(args, "--data"),
|
|
877
1063
|
auth: flag2(args, "--auth"),
|
|
878
|
-
envs: flag2(args, "--envs")?.split(",")
|
|
1064
|
+
envs: flag2(args, "--envs")?.split(","),
|
|
1065
|
+
port: portFlag ? Number(portFlag) : void 0
|
|
879
1066
|
});
|
|
880
1067
|
const entry = next.tools.find((t) => t.name === name);
|
|
881
1068
|
const data = entry?.data ?? "none";
|
|
@@ -914,7 +1101,10 @@ async function addCommand(args) {
|
|
|
914
1101
|
if (existsSync6(toolTf)) {
|
|
915
1102
|
console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
|
|
916
1103
|
} else {
|
|
917
|
-
writeFileSync3(
|
|
1104
|
+
writeFileSync3(
|
|
1105
|
+
toolTf,
|
|
1106
|
+
emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
|
|
1107
|
+
);
|
|
918
1108
|
console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
|
|
919
1109
|
}
|
|
920
1110
|
if (!args.includes("--no-tokens")) {
|
|
@@ -1157,7 +1347,6 @@ jobs:
|
|
|
1157
1347
|
`;
|
|
1158
1348
|
}
|
|
1159
1349
|
function deployListenerYml(name, toolRepo) {
|
|
1160
|
-
const SECRET = `${name.toUpperCase().replace(/-/g, "_")}_OCI_CONTAINER_INSTANCE_OCID`;
|
|
1161
1350
|
return `name: greenlight-deploy-${name}
|
|
1162
1351
|
|
|
1163
1352
|
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
@@ -1177,15 +1366,39 @@ jobs:
|
|
|
1177
1366
|
- uses: jdx/mise-action@v2
|
|
1178
1367
|
- run: pnpm install --frozen-lockfile
|
|
1179
1368
|
- run: pip install --quiet oci-cli
|
|
1180
|
-
- name: Deploy (
|
|
1369
|
+
- name: Deploy (resolve instance OCID by name -> restart -> re-pull GHCR image)
|
|
1181
1370
|
env:
|
|
1182
|
-
|
|
1183
|
-
OCI_CLI_TENANCY: \${{ secrets.
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1371
|
+
# The OCI CLI reuses the SAME TF_VAR_OCI_* secrets the apply uses \u2014 one secret set.
|
|
1372
|
+
OCI_CLI_TENANCY: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
|
|
1373
|
+
OCI_CLI_USER: \${{ secrets.TF_VAR_OCI_USER_OCID }}
|
|
1374
|
+
OCI_CLI_FINGERPRINT: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
|
|
1375
|
+
OCI_CLI_KEY_CONTENT: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
|
|
1376
|
+
OCI_CLI_REGION: \${{ secrets.TF_VAR_OCI_REGION }}
|
|
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
|
|
1189
1402
|
- name: Report status back to ${toolRepo}
|
|
1190
1403
|
if: \${{ always() && github.event.client_payload.sha != '' }}
|
|
1191
1404
|
env:
|
|
@@ -1306,8 +1519,9 @@ Next:
|
|
|
1306
1519
|
(in the wrapper) review infra/${name}.tf, then commit the submodule + infra + listener:
|
|
1307
1520
|
git add .gitmodules ${toolRel} infra/${name}.tf verify/${name}.config.ts greenlight.config.ts .github
|
|
1308
1521
|
git commit && git push # CI (infra.yml) applies. Tool's CI builds; wrapper deploys.${target === "oci" ? `
|
|
1309
|
-
Secrets:
|
|
1310
|
-
|
|
1522
|
+
Secrets (guided): greenlight secrets gather ${name} --repo <wrapper> # TF_VAR_OCI_* + GREENLIGHT_STATUS_TOKEN
|
|
1523
|
+
greenlight secrets gather ${name} --repo ${slug} # GREENLIGHT_DISPATCH_TOKEN
|
|
1524
|
+
The instance OCID is auto-resolved by the deploy workflow (by display name) \u2014 nothing to set.` : ""}`);
|
|
1311
1525
|
}
|
|
1312
1526
|
async function adoptStandalone(ctx) {
|
|
1313
1527
|
const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
|
|
@@ -1529,7 +1743,7 @@ async function deployCommand(args) {
|
|
|
1529
1743
|
}
|
|
1530
1744
|
const { config } = await loadManifest();
|
|
1531
1745
|
const entry = resolveEntry(config, name);
|
|
1532
|
-
if (entry.external) {
|
|
1746
|
+
if (entry.external && entry.target !== "oci") {
|
|
1533
1747
|
throw new Error(`"${name}" is external (registry pointer) \u2014 deploy it from its own repo`);
|
|
1534
1748
|
}
|
|
1535
1749
|
const adapter = createAdapter(entry.target, { domain: config.domain, name: entry.name });
|
|
@@ -1613,7 +1827,7 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
|
1613
1827
|
// src/commands/init.ts
|
|
1614
1828
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1615
1829
|
import { resolve as resolve8 } from "path";
|
|
1616
|
-
import { createInterface as
|
|
1830
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
1617
1831
|
function flag5(args, name) {
|
|
1618
1832
|
const i = args.indexOf(name);
|
|
1619
1833
|
return i >= 0 ? args[i + 1] : void 0;
|
|
@@ -1630,7 +1844,7 @@ async function initCommand(args) {
|
|
|
1630
1844
|
let domain = flag5(args, "--domain");
|
|
1631
1845
|
if (!domain) {
|
|
1632
1846
|
if (!process.stdin.isTTY) throw new Error("init needs --domain <domain> (no TTY for prompts)");
|
|
1633
|
-
const rl =
|
|
1847
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
1634
1848
|
domain = (await rl.question("Domain (e.g. example.dev): ")).trim();
|
|
1635
1849
|
rl.close();
|
|
1636
1850
|
}
|
|
@@ -1901,6 +2115,7 @@ var HELP = `greenlight <command>
|
|
|
1901
2115
|
preview <name> [--port <n>] build + serve locally + verify (one command)
|
|
1902
2116
|
verify <name> [--env <env> | --url <url>] run the verify harness against the URL
|
|
1903
2117
|
promote <name> [--perform] [--push] gated develop -> main fast-forward
|
|
2118
|
+
secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
|
|
1904
2119
|
secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
|
|
1905
2120
|
agent sync write the loop skill + CLAUDE.md block into this repo
|
|
1906
2121
|
adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
|
|
@@ -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