create-op-node 0.9.0 → 0.10.0

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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { execa } from 'execa';
6
6
  import { Octokit } from '@octokit/rest';
7
7
  import { randomBytes, createHmac } from 'crypto';
8
8
  import { join, resolve, dirname } from 'path';
9
- import { mkdir, writeFile, access, rm, chmod } from 'fs/promises';
9
+ import { mkdir, writeFile, access, chmod, rename, rm } from 'fs/promises';
10
10
  import { homedir } from 'os';
11
11
  import { readFileSync } from 'fs';
12
12
  import * as tls from 'tls';
@@ -237,7 +237,10 @@ var FRIENDLY = {
237
237
  "jwt-secret": "JWT signing secret",
238
238
  "supabase-anon-key": "Supabase anon key",
239
239
  "supabase-service-role-key": "Supabase service role key",
240
- "dashboard-password": "Supabase Studio dashboard password"
240
+ "dashboard-password": "Supabase Studio dashboard password",
241
+ "prompts-db-password": "prompt-service Postgres password",
242
+ "prompt-service-api-key": "prompt-service HMAC API key",
243
+ "prompt-service-admin-api-key": "prompt-service admin API key"
241
244
  };
242
245
  function labelFor(coords) {
243
246
  return `Opus Populi (${coords.region}) \u2014 ${FRIENDLY[coords.account]}`;
@@ -448,6 +451,9 @@ function generatePostgresPassword() {
448
451
  function generateDashboardPassword() {
449
452
  return base64url(randomBytes(24));
450
453
  }
454
+ function generateHmacApiKey() {
455
+ return base64url(randomBytes(32));
456
+ }
451
457
  function generateJwtSecret() {
452
458
  return randomBytes(48).toString("base64");
453
459
  }
@@ -1403,6 +1409,139 @@ async function teardownLaunchAgent(paths, opts = {}) {
1403
1409
  return { ok: steps.every((s) => s.ok), steps };
1404
1410
  }
1405
1411
 
1412
+ // src/lib/op-compose-script.ts
1413
+ var REGION_RE = /^[a-z0-9-]{2,32}$/;
1414
+ var PROMPT_SERVICE_URL_RE = /^[A-Za-z0-9:/_.-]+$/;
1415
+ function renderOpComposeScript(input) {
1416
+ if (!REGION_RE.test(input.region)) {
1417
+ throw new Error(
1418
+ `region ${JSON.stringify(input.region)} contains characters not allowed in a launchd / Keychain service identifier`
1419
+ );
1420
+ }
1421
+ if (input.promptServiceUrl !== void 0 && !PROMPT_SERVICE_URL_RE.test(input.promptServiceUrl)) {
1422
+ throw new Error(
1423
+ `promptServiceUrl ${JSON.stringify(input.promptServiceUrl)} contains characters not allowed in a wrapper export`
1424
+ );
1425
+ }
1426
+ const promptServiceUrlExport = input.promptServiceUrl !== void 0 ? `# Backend services call prompt-service via this URL. Service-level env
1427
+ # from docker-compose-prompt-service.yml overlay (when layered) wins
1428
+ # over this default \u2014 so colocated deployments still hit
1429
+ # http://opuspopuli-prompts:3210 even though we may have a public URL
1430
+ # baked in here.
1431
+ export PROMPT_SERVICE_URL="\${PROMPT_SERVICE_URL:-${input.promptServiceUrl}}"
1432
+
1433
+ ` : "";
1434
+ return `#!/bin/bash
1435
+ # op-compose \u2014 wrapper that reads region secrets from macOS Keychain just
1436
+ # before invoking \`docker compose\`. Auto-generated by
1437
+ # \`create-op-node bootstrap --region ${input.region}\`. Do not edit by
1438
+ # hand; re-run bootstrap to regenerate.
1439
+ #
1440
+ # Why: launchctl setenv only propagates to launchd-spawned processes, so
1441
+ # SSH shells and pre-existing Terminal.app instances never see the env
1442
+ # vars bootstrap exports. This wrapper hydrates the docker compose
1443
+ # subprocess directly, so every invocation works regardless of how the
1444
+ # operator's shell was started.
1445
+ #
1446
+ # Usage:
1447
+ # ./bin/op-compose -f docker-compose-prod.yml up -d
1448
+ # ./bin/op-compose -f docker-compose-prod.yml pull
1449
+ # ./bin/op-compose -f docker-compose-prod.yml down
1450
+
1451
+ set -euo pipefail
1452
+
1453
+ SVC="org.opuspopuli.${input.region}"
1454
+
1455
+ require_secret() {
1456
+ local account="$1"
1457
+ local value
1458
+ if ! value=$(security find-generic-password -s "$SVC" -a "$account" -w 2>/dev/null); then
1459
+ echo "op-compose: missing Keychain entry '$account' under service '$SVC'." >&2
1460
+ echo "Run: create-op-node bootstrap --region ${input.region}" >&2
1461
+ exit 1
1462
+ fi
1463
+ printf '%s' "$value"
1464
+ }
1465
+
1466
+ optional_secret() {
1467
+ local account="$1"
1468
+ security find-generic-password -s "$SVC" -a "$account" -w 2>/dev/null || true
1469
+ }
1470
+
1471
+ # Bootstrap-critical: Postgres + Supabase admin credentials.
1472
+ export PGSODIUM_ROOT_KEY="$(require_secret pgsodium-root-key)"
1473
+ export POSTGRES_PASSWORD="$(require_secret postgres-password)"
1474
+ export JWT_SECRET="$(require_secret jwt-secret)"
1475
+ export SUPABASE_ANON_KEY="$(require_secret supabase-anon-key)"
1476
+ export SUPABASE_SERVICE_ROLE_KEY="$(require_secret supabase-service-role-key)"
1477
+ export DASHBOARD_PASSWORD="$(require_secret dashboard-password)"
1478
+
1479
+ # AUTH_JWT_SECRET historically equals JWT_SECRET in dev/UAT \u2014 supply that
1480
+ # default if the operator hasn't set one independently.
1481
+ export AUTH_JWT_SECRET="\${AUTH_JWT_SECRET:-$JWT_SECRET}"
1482
+
1483
+ # Optional: Cloudflare Tunnel token (only present in non-local-only mode).
1484
+ TUNNEL_TOKEN_VAL="$(optional_secret tunnel-token)"
1485
+ if [ -n "$TUNNEL_TOKEN_VAL" ]; then
1486
+ export TUNNEL_TOKEN="$TUNNEL_TOKEN_VAL"
1487
+ fi
1488
+
1489
+ # SUPABASE_URL is not a secret \u2014 read from env if the operator set one,
1490
+ # default to localhost for --local-only.
1491
+ export SUPABASE_URL="\${SUPABASE_URL:-http://localhost:8000}"
1492
+
1493
+ ${promptServiceUrlExport}# prompt-service credentials. Always exported \u2014 harmless when the operator
1494
+ # is NOT colocating prompt-service (the main compose doesn't read these
1495
+ # vars; the overlay does). The PROMPT_SERVICE_API_KEYS list is built from
1496
+ # the operator's region + the single HMAC key in Keychain, matching the
1497
+ # prompt-service env format \`<region>:<key>,<region>:<key>,\u2026\`.
1498
+ PROMPTS_DB_PASSWORD_VAL="$(optional_secret prompts-db-password)"
1499
+ PROMPT_SERVICE_API_KEY_VAL="$(optional_secret prompt-service-api-key)"
1500
+ PROMPT_SERVICE_ADMIN_API_KEY_VAL="$(optional_secret prompt-service-admin-api-key)"
1501
+ if [ -n "$PROMPTS_DB_PASSWORD_VAL" ]; then
1502
+ export PROMPTS_DB_PASSWORD="$PROMPTS_DB_PASSWORD_VAL"
1503
+ fi
1504
+ if [ -n "$PROMPT_SERVICE_API_KEY_VAL" ]; then
1505
+ export PROMPT_SERVICE_API_KEY="$PROMPT_SERVICE_API_KEY_VAL"
1506
+ # API_KEYS env on prompt-service is \`<region>:<key>,\u2026\` \u2014 render the
1507
+ # single-region pair here so prompt-service accepts the same key the
1508
+ # backend sends. The prompts team adds additional regions on the
1509
+ # central deployment for Phase 2.
1510
+ export PROMPT_SERVICE_API_KEYS="${input.region}:$PROMPT_SERVICE_API_KEY_VAL"
1511
+ fi
1512
+ if [ -n "$PROMPT_SERVICE_ADMIN_API_KEY_VAL" ]; then
1513
+ export PROMPT_SERVICE_ADMIN_API_KEYS="$PROMPT_SERVICE_ADMIN_API_KEY_VAL"
1514
+ fi
1515
+
1516
+ exec docker compose "$@"
1517
+ `;
1518
+ }
1519
+
1520
+ // src/lib/op-compose-install.ts
1521
+ async function installOpComposeWrapper(input) {
1522
+ let content;
1523
+ try {
1524
+ content = renderOpComposeScript({
1525
+ region: input.region,
1526
+ ...input.promptServiceUrl !== void 0 ? { promptServiceUrl: input.promptServiceUrl } : {}
1527
+ });
1528
+ } catch (err) {
1529
+ return { ok: false, reason: err.message };
1530
+ }
1531
+ const binDir = join(input.repoDir, "bin");
1532
+ const target = join(binDir, "op-compose");
1533
+ const tmp = `${target}.tmp.${process.pid}`;
1534
+ try {
1535
+ await mkdir(binDir, { recursive: true });
1536
+ await writeFile(tmp, content, { mode: 493 });
1537
+ await chmod(tmp, 493);
1538
+ await rename(tmp, target);
1539
+ return { ok: true, path: target };
1540
+ } catch (err) {
1541
+ return { ok: false, reason: `writing ${target} failed: ${err.message}` };
1542
+ }
1543
+ }
1544
+
1406
1545
  // src/lib/docker.ts
1407
1546
  var GHCR_REGISTRY = "ghcr.io";
1408
1547
  async function loginToGhcr() {
@@ -1449,16 +1588,19 @@ function composeArgs(opts, sub) {
1449
1588
  const profiles = (opts.profiles ?? []).flatMap((pr) => ["--profile", pr]);
1450
1589
  return ["compose", ...files, ...env, ...profiles, ...sub];
1451
1590
  }
1591
+ function execOpts(opts) {
1592
+ return opts.env ? { cwd: opts.cwd, env: opts.env } : { cwd: opts.cwd };
1593
+ }
1452
1594
  async function composePull(opts) {
1453
- const res = await safeExeca("docker", composeArgs(opts, ["pull"]));
1595
+ const res = await safeExeca("docker", composeArgs(opts, ["pull"]), execOpts(opts));
1454
1596
  return result(res, "compose pull");
1455
1597
  }
1456
1598
  async function composeUp(opts) {
1457
- const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
1599
+ const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]), execOpts(opts));
1458
1600
  return result(res, "compose up");
1459
1601
  }
1460
1602
  async function composeRemoveService(opts, service) {
1461
- const res = await safeExeca("docker", composeArgs(opts, ["rm", "-sfv", service]));
1603
+ const res = await safeExeca("docker", composeArgs(opts, ["rm", "-sfv", service]), execOpts(opts));
1462
1604
  return result(res, `compose rm ${service}`);
1463
1605
  }
1464
1606
  async function composeDown(opts) {
@@ -1471,7 +1613,7 @@ async function composeDown(opts) {
1471
1613
  opts.wipeVolumes ? "-v" : "",
1472
1614
  opts.removeImages ? `--rmi ${opts.removeImages}` : ""
1473
1615
  ].filter(Boolean).join(" ");
1474
- const res = await safeExeca("docker", composeArgs(opts, flags));
1616
+ const res = await safeExeca("docker", composeArgs(opts, flags), execOpts(opts));
1475
1617
  return result(res, label);
1476
1618
  }
1477
1619
  async function dockerLogout(registry = GHCR_REGISTRY) {
@@ -1517,7 +1659,7 @@ function normalize(raw) {
1517
1659
  return { name, state, health, exitCode };
1518
1660
  }
1519
1661
  async function composePs(opts) {
1520
- const res = await safeExeca("docker", composeArgs(opts, ["ps", "--format", "json"]));
1662
+ const res = await safeExeca("docker", composeArgs(opts, ["ps", "--format", "json"]), execOpts(opts));
1521
1663
  if (res === null || res.exitCode !== 0) return null;
1522
1664
  return parseComposePs(res.stdout);
1523
1665
  }
@@ -1776,6 +1918,16 @@ var bootstrapCommand = new Command("bootstrap").description(
1776
1918
  "--supabase-url <url>",
1777
1919
  "Public-facing Supabase URL (what browsers + microservices use). Default: `http://localhost:8000` in --local-only mode, otherwise `https://supabase.<domain>` (which the operator can override)."
1778
1920
  )
1921
+ ).addOption(
1922
+ new Option(
1923
+ "--node-type <type>",
1924
+ "Deployment topology. `region` (default) = region node connecting to remote prompt-service. `region-with-prompts` = region node + colocated prompt-service overlay (team/dev use). `prompts-only` = future stub for the central prompts deployment."
1925
+ ).choices(["region", "region-with-prompts", "prompts-only"])
1926
+ ).addOption(
1927
+ new Option(
1928
+ "--prompt-service-url <url>",
1929
+ "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)."
1930
+ )
1779
1931
  ).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
1780
1932
  new Option(
1781
1933
  "--local-only",
@@ -1783,6 +1935,32 @@ var bootstrapCommand = new Command("bootstrap").description(
1783
1935
  ).default(false)
1784
1936
  ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1785
1937
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
1938
+ const nodeType = opts.nodeType ? opts.nodeType : unwrap(
1939
+ await p3.select({
1940
+ message: "What kind of node are you provisioning?",
1941
+ options: [
1942
+ {
1943
+ value: "region",
1944
+ label: "Region node (civic data for one region, remote prompt-service)"
1945
+ },
1946
+ {
1947
+ value: "region-with-prompts",
1948
+ label: "Region + colocated prompt-service (team / dev)"
1949
+ },
1950
+ {
1951
+ value: "prompts-only",
1952
+ label: "Prompt-service node (central prompts deployment \u2014 future)"
1953
+ }
1954
+ ],
1955
+ initialValue: "region"
1956
+ })
1957
+ );
1958
+ if (nodeType === "prompts-only") {
1959
+ p3.cancel(
1960
+ "prompts-only node deployment is not yet implemented. This flag exists so the CLI surface is stable for when the prompts team deploys the central prompts.opuspopuli.org instance \u2014 but the deployment stack (compose file, healthchecks, observability) is filled in later, when there is an actual central prompt-service to deploy."
1961
+ );
1962
+ process.exit(1);
1963
+ }
1786
1964
  const region = opts.region ? opts.region : unwrap(
1787
1965
  await p3.text({
1788
1966
  message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
@@ -1968,6 +2146,46 @@ var bootstrapCommand = new Command("bootstrap").description(
1968
2146
  validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
1969
2147
  generate: generateDashboardPassword
1970
2148
  });
2149
+ if (nodeType === "region-with-prompts") {
2150
+ await loadSecret({
2151
+ region,
2152
+ account: "prompts-db-password",
2153
+ label: "prompt-service Postgres password",
2154
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2155
+ generate: generatePostgresPassword
2156
+ });
2157
+ await loadSecret({
2158
+ region,
2159
+ account: "prompt-service-api-key",
2160
+ label: "prompt-service HMAC API key",
2161
+ // Same URL-safe alphabet as other HMAC keys; embedded in the
2162
+ // `<region>:<key>` API_KEYS list and in Authorization headers.
2163
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2164
+ generate: generateHmacApiKey
2165
+ });
2166
+ await loadSecret({
2167
+ region,
2168
+ account: "prompt-service-admin-api-key",
2169
+ label: "prompt-service admin API key",
2170
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2171
+ generate: generateHmacApiKey
2172
+ });
2173
+ } else {
2174
+ await loadSecret({
2175
+ region,
2176
+ account: "prompt-service-api-key",
2177
+ label: "prompt-service HMAC API key (issued by the prompts team)",
2178
+ placeholder: "paste the region-specific HMAC key",
2179
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)"
2180
+ });
2181
+ }
2182
+ const promptServiceUrl = nodeType === "region-with-prompts" ? "http://opuspopuli-prompts:3210" : opts.promptServiceUrl ?? "https://prompts.opuspopuli.org";
2183
+ if (!SAFE_URL_RE.test(promptServiceUrl)) {
2184
+ p3.cancel(
2185
+ `--prompt-service-url ${JSON.stringify(promptServiceUrl)} contains characters outside the allowed URL set`
2186
+ );
2187
+ process.exit(1);
2188
+ }
1971
2189
  let supabaseUrl;
1972
2190
  if (opts.supabaseUrl) {
1973
2191
  supabaseUrl = opts.supabaseUrl;
@@ -2014,6 +2232,15 @@ var bootstrapCommand = new Command("bootstrap").description(
2014
2232
  )
2015
2233
  );
2016
2234
  }
2235
+ const wrapperSpin = p3.spinner();
2236
+ wrapperSpin.start("Installing bin/op-compose wrapper\u2026");
2237
+ const wrapper = await installOpComposeWrapper({ repoDir: repoPath, region, promptServiceUrl });
2238
+ if (!wrapper.ok) {
2239
+ wrapperSpin.stop(pc2.red("\u2717 op-compose wrapper install failed."));
2240
+ p3.cancel(wrapper.reason ?? "op-compose wrapper install failed.");
2241
+ process.exit(1);
2242
+ }
2243
+ wrapperSpin.stop(pc2.green(`\u2713 Installed ${wrapper.path} (mode 0755).`));
2017
2244
  const ghcrSpin = p3.spinner();
2018
2245
  ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
2019
2246
  const ghcr = await loginToGhcr();
@@ -2066,17 +2293,31 @@ var bootstrapCommand = new Command("bootstrap").description(
2066
2293
  const profileFlag = opts.localOnly ? "" : "--profile public ";
2067
2294
  p3.outro(
2068
2295
  pc2.cyan(
2069
- `Stack-up skipped. Run \`docker compose -f ${composeFile.join(" -f ")} ${profileFlag}pull && docker compose -f ${composeFile.join(" -f ")} ${profileFlag}up -d\` from ${repoPath} when ready.`
2296
+ `Stack-up skipped. Run \`./bin/op-compose -f ${composeFile.join(" -f ")} ${profileFlag}pull && ./bin/op-compose -f ${composeFile.join(" -f ")} ${profileFlag}up -d\` from ${repoPath} when ready. (op-compose hydrates Keychain secrets per-invocation; use it instead of raw \`docker compose\`.)`
2070
2297
  )
2071
2298
  );
2072
2299
  return;
2073
2300
  }
2074
2301
  const composeFiles = resolveComposeFiles(repoPath, composeFile);
2302
+ const composeEnv = {
2303
+ PGSODIUM_ROOT_KEY: pgsodiumKey,
2304
+ POSTGRES_PASSWORD: postgresPassword,
2305
+ JWT_SECRET: jwtSecret,
2306
+ SUPABASE_ANON_KEY: supabaseAnonKey,
2307
+ SUPABASE_SERVICE_ROLE_KEY: supabaseServiceRoleKey,
2308
+ DASHBOARD_PASSWORD: dashboardPassword,
2309
+ SUPABASE_URL: supabaseUrl,
2310
+ AUTH_JWT_SECRET: process.env["AUTH_JWT_SECRET"] ?? jwtSecret,
2311
+ ...tunnelToken !== void 0 ? { TUNNEL_TOKEN: tunnelToken } : {},
2312
+ ...llmModel ? { LLM_MODEL: llmModel } : {},
2313
+ ...embeddingModel ? { EMBEDDINGS_MODEL: embeddingModel } : {}
2314
+ };
2075
2315
  const composeOpts = {
2076
2316
  files: composeFiles,
2077
2317
  cwd: repoPath,
2078
2318
  ...opts.envFile ? { envFile: opts.envFile } : {},
2079
- profiles: opts.localOnly ? LOCAL_PROFILES : PUBLIC_PROFILES
2319
+ profiles: opts.localOnly ? LOCAL_PROFILES : PUBLIC_PROFILES,
2320
+ env: composeEnv
2080
2321
  };
2081
2322
  if (opts.localOnly) {
2082
2323
  const evict = p3.spinner();
@@ -2372,7 +2613,7 @@ async function loadSecret(input) {
2372
2613
  }
2373
2614
  return pasted;
2374
2615
  }
2375
- var REGION_RE = /^[a-z0-9-]{2,32}$/;
2616
+ var REGION_RE2 = /^[a-z0-9-]{2,32}$/;
2376
2617
  var RESET_PHASES = {
2377
2618
  STOP_STACK: "Stop stack",
2378
2619
  LAUNCH_AGENT: "LaunchAgent",
@@ -2529,10 +2770,10 @@ var resetCommand = new Command("reset").description(
2529
2770
  await p3.text({
2530
2771
  message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
2531
2772
  placeholder: "us-ca",
2532
- validate: (v) => REGION_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
2773
+ validate: (v) => REGION_RE2.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
2533
2774
  })
2534
2775
  );
2535
- if (!REGION_RE.test(region)) {
2776
+ if (!REGION_RE2.test(region)) {
2536
2777
  p3.cancel(`--region ${JSON.stringify(region)} is not a valid region slug.`);
2537
2778
  process.exit(2);
2538
2779
  }
@@ -2577,6 +2818,7 @@ var resetCommand = new Command("reset").description(
2577
2818
  if (repoPath) {
2578
2819
  runningContainers = await composePs({
2579
2820
  files: resolveComposeFiles(repoPath, opts.composeFile),
2821
+ cwd: repoPath,
2580
2822
  ...opts.envFile ? { envFile: opts.envFile } : {}
2581
2823
  });
2582
2824
  }
@@ -4092,7 +4334,7 @@ async function looksLikeRegionsRepo(dir) {
4092
4334
  }
4093
4335
 
4094
4336
  // src/cli.ts
4095
- var VERSION = "0.9.0";
4337
+ var VERSION = "0.10.0";
4096
4338
  var program = new Command();
4097
4339
  program.name("create-op-node").description(
4098
4340
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."