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 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
- `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
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: true,
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.10.17";
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
- if (process.argv.length === 2) {
4900
- process.argv.push("init");
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(`