create-op-node 0.10.17 → 0.11.1
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 +3 -3
- package/dist/cli.js +130 -37
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npx create-op-node
|
|
|
10
10
|
That's it. The wizard walks you through:
|
|
11
11
|
|
|
12
12
|
1. **Cloudflare** — verifies your API token's 5 scopes (Zone Read, DNS Edit, Tunnel Edit, R2 Storage Edit, Pages Edit). Fails fast with a specific scope name if anything's missing.
|
|
13
|
-
2. **GitHub** — creates your region's node repo from the [`OpusPopuli/opuspopuli-node`](https://github.com/OpusPopuli/opuspopuli-node) template via the GitHub API. Seeds the 5 required GitHub Secrets (Cloudflare token, account ID, zone ID, Terraform Cloud token, TFC org) for you.
|
|
13
|
+
2. **GitHub** — creates your region's node repo from the [`OpusPopuli/opuspopuli-node`](https://github.com/OpusPopuli/opuspopuli-node) template via the GitHub API. Seeds the 5 required GitHub Secrets (Cloudflare token, account ID, zone ID, Terraform Cloud token, TFC org) for you — sealed-box encrypted with the repo's public key and PUT under your own token, never shelled out to `gh`. Your GitHub token therefore needs **Contents: write + Actions Secrets: write + Pull requests: write** (fine-grained), or `repo` scope (classic).
|
|
14
14
|
3. **Terraform Cloud** — verifies your TFC token, prepares the workspace.
|
|
15
15
|
4. **First PR** — writes `environments/prod.tfvars` from your answers, commits, opens the first PR. The node repo's `cloudflare-infra.yml` workflow runs `terraform plan` against the PR; on merge to `main` it applies — Tunnel, DNS, R2 buckets, and Pages project come up automatically.
|
|
16
16
|
5. **pgsodium master key** — generates a fresh 64-hex root key, stores it in your **macOS login Keychain** as `org.opuspopuli.<region>/pgsodium-root-key`. No third-party password manager required.
|
|
@@ -26,7 +26,7 @@ Then on the Mac Studio itself:
|
|
|
26
26
|
npx create-op-node bootstrap
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Configures macOS power settings, installs Homebrew + the CLI tool list, sets up Docker Desktop + Tailscale + Ollama, clones the node repo you created, reads the pgsodium key + Tunnel token from the Studio's Keychain (or prompts you to paste them once, then persists for re-runs), writes the LaunchAgent plist, logs into ghcr.io, pulls + warms the LLM model, and finally `docker compose --profile public pull && up -d` brings the whole stack online. Health-check loop waits until all containers are `(healthy)`.
|
|
29
|
+
Configures macOS power settings, installs Homebrew + the CLI tool list, sets up Docker Desktop + Tailscale + Ollama, clones the node repo you created, reads the pgsodium key + Tunnel token from the Studio's Keychain (or prompts you to paste them once, then persists for re-runs), writes the LaunchAgent plist, logs into ghcr.io, pulls + warms the LLM model, **verifies the cosign signature of every `ghcr.io/opuspopuli/*` image before pulling** (fail-closed — `--skip-signature-check` to bypass), and finally `docker compose --profile public pull && up -d` brings the whole stack online. Health-check loop waits until all containers are `(healthy)`.
|
|
30
30
|
|
|
31
31
|
### Choosing the LLM model
|
|
32
32
|
|
|
@@ -510,7 +510,7 @@ The CLI itself never holds any credentials beyond the scope of a single command
|
|
|
510
510
|
|
|
511
511
|
**`init` — fully wired.** Full Phase 1 of the runbook: prompts → Cloudflare 5-scope probe → Terraform Cloud verify → GitHub template clone → 5 repo secrets seeded → branch + prod.tfvars committed → PR opened → pgsodium key generated → (after operator merges PR) Terraform apply polled → Tunnel token retrieved + saved to the macOS Keychain.
|
|
512
512
|
|
|
513
|
-
**`bootstrap` — fully wired.** Phase 2 on the Mac Studio: macOS sanity (auto-restart, disk sleep), Homebrew + tool installs (gh, pnpm, jq, cloudflared, rclone, ollama, docker, tailscale), GitHub + Tailscale signin prompts, pgsodium key + Tunnel token read from the Studio's Keychain (or pasted in once if first run on that machine, then persisted), LaunchAgent written + loaded, ghcr.io login, Ollama models pulled + warmed, region repo located or cloned, `docker compose pull && up -d`, health-check loop until everything reports `(healthy)`.
|
|
513
|
+
**`bootstrap` — fully wired.** Phase 2 on the Mac Studio: macOS sanity (auto-restart, disk sleep), Homebrew + tool installs (gh, pnpm, jq, cloudflared, rclone, ollama, cosign, docker, tailscale), GitHub + Tailscale signin prompts, pgsodium key + Tunnel token read from the Studio's Keychain (or pasted in once if first run on that machine, then persisted), LaunchAgent written + loaded, ghcr.io login, Ollama models pulled + warmed, region repo located or cloned, cosign signature verification of the `ghcr.io/opuspopuli/*` images (fail-closed; `--skip-signature-check` bypasses, `--certificate-identity-regexp` overrides the pinned identity), `docker compose pull && up -d`, health-check loop until everything reports `(healthy)`.
|
|
514
514
|
|
|
515
515
|
**`verify` — scaffold stub.** Type-safe argument parsing only; prints a roadmap-style message and exits.
|
|
516
516
|
|
package/dist/cli.js
CHANGED
|
@@ -1304,6 +1304,8 @@ var STUDIO_PACKAGES = [
|
|
|
1304
1304
|
{ name: "cloudflared", kind: "formula" },
|
|
1305
1305
|
{ name: "rclone", kind: "formula" },
|
|
1306
1306
|
{ name: "ollama", kind: "formula" },
|
|
1307
|
+
// Required for the fail-closed image-signature gate in `bootstrap` (#34).
|
|
1308
|
+
{ name: "cosign", kind: "formula" },
|
|
1307
1309
|
{ name: "docker", kind: "cask" },
|
|
1308
1310
|
{ name: "tailscale", kind: "cask" }
|
|
1309
1311
|
];
|
|
@@ -1507,7 +1509,11 @@ function validatePlistInput(input) {
|
|
|
1507
1509
|
}
|
|
1508
1510
|
function buildSetenvCommand(input) {
|
|
1509
1511
|
return [
|
|
1510
|
-
`
|
|
1512
|
+
// Quote the path inside `cat` so a space-containing keyFilePath (allowed by
|
|
1513
|
+
// SAFE_PATH_RE) doesn't word-split and leave PGSODIUM_ROOT_KEY empty. The
|
|
1514
|
+
// regex rejects `"`/`$`/backticks, so the inner quotes can't be broken out
|
|
1515
|
+
// of — this is word-split safety, not new injection surface. (#36)
|
|
1516
|
+
`launchctl setenv PGSODIUM_ROOT_KEY "$(cat "${input.keyFilePath}")"`,
|
|
1511
1517
|
...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
|
|
1512
1518
|
...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
|
|
1513
1519
|
...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : [],
|
|
@@ -1584,7 +1590,7 @@ async function teardownLaunchAgent(paths, opts = {}) {
|
|
|
1584
1590
|
const unload = await safeExeca("launchctl", ["unload", paths.plistFile]);
|
|
1585
1591
|
steps.push({
|
|
1586
1592
|
step: "unload",
|
|
1587
|
-
ok:
|
|
1593
|
+
ok: unload !== null,
|
|
1588
1594
|
...unload === null ? { reason: "`launchctl` not on PATH" } : {}
|
|
1589
1595
|
});
|
|
1590
1596
|
try {
|
|
@@ -1842,6 +1848,15 @@ async function composePull(opts) {
|
|
|
1842
1848
|
const res = await safeExeca("docker", composeArgs(opts, ["pull"]), execOpts(opts));
|
|
1843
1849
|
return result(res, "compose pull");
|
|
1844
1850
|
}
|
|
1851
|
+
var OPUSPOPULI_IMAGE_PREFIX = "ghcr.io/opuspopuli/";
|
|
1852
|
+
async function composeConfigImages(opts) {
|
|
1853
|
+
const res = await safeExeca("docker", composeArgs(opts, ["config", "--images"]), execOpts(opts));
|
|
1854
|
+
if (res === null || res.exitCode !== 0) return null;
|
|
1855
|
+
return res.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1856
|
+
}
|
|
1857
|
+
function filterVerifiableImages(images, prefix = OPUSPOPULI_IMAGE_PREFIX) {
|
|
1858
|
+
return images.filter((img) => img.startsWith(prefix));
|
|
1859
|
+
}
|
|
1845
1860
|
async function composeUp(opts) {
|
|
1846
1861
|
const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]), execOpts(opts));
|
|
1847
1862
|
return result(res, "compose up");
|
|
@@ -1984,6 +1999,37 @@ function assessAllRunning(snapshots) {
|
|
|
1984
1999
|
return { kind: "healthy" };
|
|
1985
2000
|
}
|
|
1986
2001
|
|
|
2002
|
+
// src/lib/cosign.ts
|
|
2003
|
+
var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
|
2004
|
+
var DEFAULT_IDENTITY_REGEXP = "^https://github\\.com/OpusPopuli/opuspopuli/\\.github/workflows/release\\.yml@refs/heads/.*$";
|
|
2005
|
+
async function cosignVerifyImage(input) {
|
|
2006
|
+
const idRegex = input.certificateIdentityRegexp ?? DEFAULT_IDENTITY_REGEXP;
|
|
2007
|
+
const issuer = input.certificateOidcIssuer ?? COSIGN_OIDC_ISSUER;
|
|
2008
|
+
const res = await safeExeca("cosign", [
|
|
2009
|
+
"verify",
|
|
2010
|
+
"--certificate-identity-regexp",
|
|
2011
|
+
idRegex,
|
|
2012
|
+
"--certificate-oidc-issuer",
|
|
2013
|
+
issuer,
|
|
2014
|
+
input.image
|
|
2015
|
+
]);
|
|
2016
|
+
if (res === null) {
|
|
2017
|
+
return {
|
|
2018
|
+
ok: false,
|
|
2019
|
+
skipped: true,
|
|
2020
|
+
reason: "`cosign` not on PATH (install with `brew install cosign` and rerun to enable signature checks)"
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
if (res.exitCode !== 0) {
|
|
2024
|
+
return {
|
|
2025
|
+
ok: false,
|
|
2026
|
+
skipped: false,
|
|
2027
|
+
reason: `cosign verify ${input.image} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
return { ok: true, output: res.stdout || res.stderr };
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1987
2033
|
// src/lib/ollama.ts
|
|
1988
2034
|
var OLLAMA_URL = "http://localhost:11434";
|
|
1989
2035
|
var OLLAMA_HEALTH_TIMEOUT_MS = 5e3;
|
|
@@ -2197,6 +2243,16 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
2197
2243
|
"Remote prompt-service URL. Only meaningful for --node-type=region (default: https://prompts.opuspopuli.org). Ignored for --node-type=region-with-prompts (the overlay pins it to http://opuspopuli-prompts:3210)."
|
|
2198
2244
|
)
|
|
2199
2245
|
).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
|
|
2246
|
+
new Option(
|
|
2247
|
+
"--skip-signature-check",
|
|
2248
|
+
"Bypass the fail-closed cosign verification of opuspopuli images before pull (NOT recommended \u2014 trusts the registry unverified)."
|
|
2249
|
+
).default(false)
|
|
2250
|
+
).addOption(
|
|
2251
|
+
new Option(
|
|
2252
|
+
"--certificate-identity-regexp <regexp>",
|
|
2253
|
+
"Override the cosign certificate-identity regexp used to verify images (escape valve for the pinned default)."
|
|
2254
|
+
)
|
|
2255
|
+
).addOption(
|
|
2200
2256
|
new Option(
|
|
2201
2257
|
"--local-only",
|
|
2202
2258
|
"Run for local dev / testing: no Tunnel token required, cloudflared stays down. Auto-generates the pgsodium key if not in Keychain (init unnecessary)."
|
|
@@ -2666,6 +2722,62 @@ function buildComposeEnv(args) {
|
|
|
2666
2722
|
...embeddingModel ? { EMBEDDINGS_MODEL: embeddingModel } : {}
|
|
2667
2723
|
};
|
|
2668
2724
|
}
|
|
2725
|
+
function planSignatureGate(allImages, opts) {
|
|
2726
|
+
if (opts.skipSignatureCheck) return { kind: "skip" };
|
|
2727
|
+
if (allImages === null) return { kind: "enumerate-failed" };
|
|
2728
|
+
const images = filterVerifiableImages(allImages);
|
|
2729
|
+
if (images.length === 0) return { kind: "no-images" };
|
|
2730
|
+
return { kind: "verify", images };
|
|
2731
|
+
}
|
|
2732
|
+
async function verifyImageSignaturesPhase(composeOpts, opts) {
|
|
2733
|
+
const allImages = opts.skipSignatureCheck ? null : await composeConfigImages(composeOpts);
|
|
2734
|
+
const plan = planSignatureGate(allImages, opts);
|
|
2735
|
+
if (plan.kind === "skip") {
|
|
2736
|
+
p3.note(
|
|
2737
|
+
pc2.yellow(
|
|
2738
|
+
"\u26A0 Skipping cosign signature verification (--skip-signature-check). Images will be pulled unverified \u2014 you are trusting the registry contents."
|
|
2739
|
+
),
|
|
2740
|
+
"Supply chain"
|
|
2741
|
+
);
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (plan.kind === "enumerate-failed") {
|
|
2745
|
+
p3.cancel(
|
|
2746
|
+
"`docker compose config --images` failed, so signatures can't be verified. Fix the compose config (or re-run with --skip-signature-check to bypass)."
|
|
2747
|
+
);
|
|
2748
|
+
process.exit(1);
|
|
2749
|
+
}
|
|
2750
|
+
if (plan.kind === "no-images") {
|
|
2751
|
+
p3.note(
|
|
2752
|
+
pc2.yellow(
|
|
2753
|
+
"\u26A0 No `ghcr.io/opuspopuli/*` images in the resolved compose set, so there is nothing to verify. If you did not expect this, inspect your compose files \u2014 this gate verifies the images we publish, not the compose file itself."
|
|
2754
|
+
),
|
|
2755
|
+
"Supply chain"
|
|
2756
|
+
);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
const spin = p3.spinner();
|
|
2760
|
+
spin.start("Verifying image signatures with cosign\u2026");
|
|
2761
|
+
for (const image of plan.images) {
|
|
2762
|
+
spin.message(`cosign verify ${image}`);
|
|
2763
|
+
const verdict = await cosignVerifyImage({
|
|
2764
|
+
image,
|
|
2765
|
+
...opts.certificateIdentityRegexp ? { certificateIdentityRegexp: opts.certificateIdentityRegexp } : {}
|
|
2766
|
+
});
|
|
2767
|
+
if (!verdict.ok) {
|
|
2768
|
+
spin.stop(pc2.red(`\u2717 Signature verification failed for ${image}.`));
|
|
2769
|
+
const detail = verdict.skipped ? `${verdict.reason}
|
|
2770
|
+
cosign is required for the fail-closed signature gate \u2014 install it (\`brew install cosign\`, or re-run without --skip-brew) or bypass with --skip-signature-check.` : verdict.reason;
|
|
2771
|
+
p3.cancel(detail);
|
|
2772
|
+
process.exit(1);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
spin.stop(
|
|
2776
|
+
pc2.green(
|
|
2777
|
+
`\u2713 ${plan.images.length} image signature${plan.images.length === 1 ? "" : "s"} verified.`
|
|
2778
|
+
)
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2669
2781
|
async function runStackPhase(args) {
|
|
2670
2782
|
const { opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel } = args;
|
|
2671
2783
|
if (opts.skipStack) {
|
|
@@ -2697,6 +2809,7 @@ async function runStackPhase(args) {
|
|
|
2697
2809
|
rm2.ok ? pc2.green("\u2713 cloudflared not present (or removed).") : pc2.yellow(`\u26A0 cloudflared eviction reported: ${rm2.reason ?? "unknown"} \u2014 continuing.`)
|
|
2698
2810
|
);
|
|
2699
2811
|
}
|
|
2812
|
+
await verifyImageSignaturesPhase(composeOpts, opts);
|
|
2700
2813
|
const pullSpin = p3.spinner();
|
|
2701
2814
|
pullSpin.start("Pulling images from ghcr.io\u2026 (~5\u201315 min first run, < 1 min cached)");
|
|
2702
2815
|
const pull = await composePull(composeOpts);
|
|
@@ -3355,37 +3468,6 @@ function reportResetOutcome(args) {
|
|
|
3355
3468
|
);
|
|
3356
3469
|
}
|
|
3357
3470
|
|
|
3358
|
-
// src/lib/cosign.ts
|
|
3359
|
-
var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
|
3360
|
-
var DEFAULT_IDENTITY_REGEXP = "^https://github\\.com/OpusPopuli/opuspopuli/\\.github/workflows/release\\.yml@refs/heads/.*$";
|
|
3361
|
-
async function cosignVerifyImage(input) {
|
|
3362
|
-
const idRegex = input.certificateIdentityRegexp ?? DEFAULT_IDENTITY_REGEXP;
|
|
3363
|
-
const issuer = input.certificateOidcIssuer ?? COSIGN_OIDC_ISSUER;
|
|
3364
|
-
const res = await safeExeca("cosign", [
|
|
3365
|
-
"verify",
|
|
3366
|
-
"--certificate-identity-regexp",
|
|
3367
|
-
idRegex,
|
|
3368
|
-
"--certificate-oidc-issuer",
|
|
3369
|
-
issuer,
|
|
3370
|
-
input.image
|
|
3371
|
-
]);
|
|
3372
|
-
if (res === null) {
|
|
3373
|
-
return {
|
|
3374
|
-
ok: false,
|
|
3375
|
-
skipped: true,
|
|
3376
|
-
reason: "`cosign` not on PATH (install with `brew install cosign` and rerun to enable signature checks)"
|
|
3377
|
-
};
|
|
3378
|
-
}
|
|
3379
|
-
if (res.exitCode !== 0) {
|
|
3380
|
-
return {
|
|
3381
|
-
ok: false,
|
|
3382
|
-
skipped: false,
|
|
3383
|
-
reason: `cosign verify ${input.image} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
|
|
3384
|
-
};
|
|
3385
|
-
}
|
|
3386
|
-
return { ok: true, output: res.stdout || res.stderr };
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
3471
|
// src/lib/http.ts
|
|
3390
3472
|
var GRAPHQL_ACCEPT = "application/graphql-response+json, application/json";
|
|
3391
3473
|
async function readBodyCapped(res) {
|
|
@@ -4885,8 +4967,18 @@ async function looksLikeRegionsRepo(dir) {
|
|
|
4885
4967
|
return hasSchema && hasRegions;
|
|
4886
4968
|
}
|
|
4887
4969
|
|
|
4970
|
+
// src/lib/cli-args.ts
|
|
4971
|
+
var GLOBAL_FLAGS = /* @__PURE__ */ new Set(["-v", "--version", "-h", "--help"]);
|
|
4972
|
+
function withDefaultSubcommand(argv, known) {
|
|
4973
|
+
const first2 = argv[2];
|
|
4974
|
+
if (first2 !== void 0 && (known.includes(first2) || GLOBAL_FLAGS.has(first2))) {
|
|
4975
|
+
return argv;
|
|
4976
|
+
}
|
|
4977
|
+
return [...argv.slice(0, 2), "init", ...argv.slice(2)];
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4888
4980
|
// src/cli.ts
|
|
4889
|
-
var VERSION = "0.
|
|
4981
|
+
var VERSION = "0.11.1";
|
|
4890
4982
|
var program = new Command();
|
|
4891
4983
|
program.name("create-op-node").description(
|
|
4892
4984
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|
|
@@ -4896,9 +4988,10 @@ program.addCommand(bootstrapCommand);
|
|
|
4896
4988
|
program.addCommand(resetCommand);
|
|
4897
4989
|
program.addCommand(verifyCommand);
|
|
4898
4990
|
program.addCommand(regionCommand);
|
|
4899
|
-
|
|
4900
|
-
process.argv
|
|
4901
|
-
|
|
4991
|
+
process.argv = withDefaultSubcommand(
|
|
4992
|
+
process.argv,
|
|
4993
|
+
program.commands.map((c) => c.name())
|
|
4994
|
+
);
|
|
4902
4995
|
await program.parseAsync(process.argv).catch((err) => {
|
|
4903
4996
|
const msg = err instanceof Error ? err.message : String(err);
|
|
4904
4997
|
console.error(`
|