create-op-node 0.9.1 → 0.10.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/dist/cli.js CHANGED
@@ -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]}`;
@@ -286,7 +289,7 @@ async function saveSecret(coords, value) {
286
289
  return {
287
290
  written: false,
288
291
  updated,
289
- reason: `security add-generic-password failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
292
+ reason: `security add-generic-password failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}${formatKeychainHint(res.exitCode)}`
290
293
  };
291
294
  }
292
295
  return { written: true, updated };
@@ -306,6 +309,47 @@ async function readSecret(coords) {
306
309
  const value = res.stdout.trim();
307
310
  return value.length > 0 ? value : null;
308
311
  }
312
+ var ERR_SEC_INTERACTION_NOT_ALLOWED = 36;
313
+ function formatKeychainHint(exitCode) {
314
+ if (exitCode === ERR_SEC_INTERACTION_NOT_ALLOWED) {
315
+ return `
316
+ Hint: the login keychain is locked (SSH sessions don't auto-unlock it).
317
+ Run \`security unlock-keychain ~/Library/Keychains/login.keychain-db\` and re-run bootstrap.`;
318
+ }
319
+ return "";
320
+ }
321
+ async function isKeychainLocked() {
322
+ const res = await safeExeca("security", [
323
+ "find-generic-password",
324
+ "-s",
325
+ `${SERVICE_PREFIX}.__op_node_lock_probe__`,
326
+ "-a",
327
+ "__nothing_here__"
328
+ ]);
329
+ if (res === null) return false;
330
+ return res.exitCode === ERR_SEC_INTERACTION_NOT_ALLOWED;
331
+ }
332
+ async function unlockKeychain(password3) {
333
+ const res = await safeExeca("security", [
334
+ "unlock-keychain",
335
+ "-p",
336
+ password3,
337
+ // Default keychain path — same as the operator's GUI login keychain
338
+ // on a stock macOS install. Lets us avoid a homedir lookup here.
339
+ `${process.env["HOME"] ?? ""}/Library/Keychains/login.keychain-db`
340
+ ]);
341
+ if (res === null) return { ok: false, reason: "`security` CLI not on PATH" };
342
+ if (res.exitCode !== 0) {
343
+ return {
344
+ ok: false,
345
+ reason: `security unlock-keychain failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
346
+ };
347
+ }
348
+ return { ok: true };
349
+ }
350
+ function isSshSession(env = process.env) {
351
+ return Boolean(env["SSH_CONNECTION"] ?? env["SSH_TTY"]);
352
+ }
309
353
  function unwrap(value) {
310
354
  if (p3.isCancel(value)) {
311
355
  p3.cancel("Cancelled.");
@@ -448,6 +492,9 @@ function generatePostgresPassword() {
448
492
  function generateDashboardPassword() {
449
493
  return base64url(randomBytes(24));
450
494
  }
495
+ function generateHmacApiKey() {
496
+ return base64url(randomBytes(32));
497
+ }
451
498
  function generateJwtSecret() {
452
499
  return randomBytes(48).toString("base64");
453
500
  }
@@ -1405,12 +1452,26 @@ async function teardownLaunchAgent(paths, opts = {}) {
1405
1452
 
1406
1453
  // src/lib/op-compose-script.ts
1407
1454
  var REGION_RE = /^[a-z0-9-]{2,32}$/;
1455
+ var PROMPT_SERVICE_URL_RE = /^[A-Za-z0-9:/_.-]+$/;
1408
1456
  function renderOpComposeScript(input) {
1409
1457
  if (!REGION_RE.test(input.region)) {
1410
1458
  throw new Error(
1411
1459
  `region ${JSON.stringify(input.region)} contains characters not allowed in a launchd / Keychain service identifier`
1412
1460
  );
1413
1461
  }
1462
+ if (input.promptServiceUrl !== void 0 && !PROMPT_SERVICE_URL_RE.test(input.promptServiceUrl)) {
1463
+ throw new Error(
1464
+ `promptServiceUrl ${JSON.stringify(input.promptServiceUrl)} contains characters not allowed in a wrapper export`
1465
+ );
1466
+ }
1467
+ const promptServiceUrlExport = input.promptServiceUrl !== void 0 ? `# Backend services call prompt-service via this URL. Service-level env
1468
+ # from docker-compose-prompt-service.yml overlay (when layered) wins
1469
+ # over this default \u2014 so colocated deployments still hit
1470
+ # http://opuspopuli-prompts:3210 even though we may have a public URL
1471
+ # baked in here.
1472
+ export PROMPT_SERVICE_URL="\${PROMPT_SERVICE_URL:-${input.promptServiceUrl}}"
1473
+
1474
+ ` : "";
1414
1475
  return `#!/bin/bash
1415
1476
  # op-compose \u2014 wrapper that reads region secrets from macOS Keychain just
1416
1477
  # before invoking \`docker compose\`. Auto-generated by
@@ -1470,6 +1531,29 @@ fi
1470
1531
  # default to localhost for --local-only.
1471
1532
  export SUPABASE_URL="\${SUPABASE_URL:-http://localhost:8000}"
1472
1533
 
1534
+ ${promptServiceUrlExport}# prompt-service credentials. Always exported \u2014 harmless when the operator
1535
+ # is NOT colocating prompt-service (the main compose doesn't read these
1536
+ # vars; the overlay does). The PROMPT_SERVICE_API_KEYS list is built from
1537
+ # the operator's region + the single HMAC key in Keychain, matching the
1538
+ # prompt-service env format \`<region>:<key>,<region>:<key>,\u2026\`.
1539
+ PROMPTS_DB_PASSWORD_VAL="$(optional_secret prompts-db-password)"
1540
+ PROMPT_SERVICE_API_KEY_VAL="$(optional_secret prompt-service-api-key)"
1541
+ PROMPT_SERVICE_ADMIN_API_KEY_VAL="$(optional_secret prompt-service-admin-api-key)"
1542
+ if [ -n "$PROMPTS_DB_PASSWORD_VAL" ]; then
1543
+ export PROMPTS_DB_PASSWORD="$PROMPTS_DB_PASSWORD_VAL"
1544
+ fi
1545
+ if [ -n "$PROMPT_SERVICE_API_KEY_VAL" ]; then
1546
+ export PROMPT_SERVICE_API_KEY="$PROMPT_SERVICE_API_KEY_VAL"
1547
+ # API_KEYS env on prompt-service is \`<region>:<key>,\u2026\` \u2014 render the
1548
+ # single-region pair here so prompt-service accepts the same key the
1549
+ # backend sends. The prompts team adds additional regions on the
1550
+ # central deployment for Phase 2.
1551
+ export PROMPT_SERVICE_API_KEYS="${input.region}:$PROMPT_SERVICE_API_KEY_VAL"
1552
+ fi
1553
+ if [ -n "$PROMPT_SERVICE_ADMIN_API_KEY_VAL" ]; then
1554
+ export PROMPT_SERVICE_ADMIN_API_KEYS="$PROMPT_SERVICE_ADMIN_API_KEY_VAL"
1555
+ fi
1556
+
1473
1557
  exec docker compose "$@"
1474
1558
  `;
1475
1559
  }
@@ -1478,7 +1562,10 @@ exec docker compose "$@"
1478
1562
  async function installOpComposeWrapper(input) {
1479
1563
  let content;
1480
1564
  try {
1481
- content = renderOpComposeScript({ region: input.region });
1565
+ content = renderOpComposeScript({
1566
+ region: input.region,
1567
+ ...input.promptServiceUrl !== void 0 ? { promptServiceUrl: input.promptServiceUrl } : {}
1568
+ });
1482
1569
  } catch (err) {
1483
1570
  return { ok: false, reason: err.message };
1484
1571
  }
@@ -1872,6 +1959,16 @@ var bootstrapCommand = new Command("bootstrap").description(
1872
1959
  "--supabase-url <url>",
1873
1960
  "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)."
1874
1961
  )
1962
+ ).addOption(
1963
+ new Option(
1964
+ "--node-type <type>",
1965
+ "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."
1966
+ ).choices(["region", "region-with-prompts", "prompts-only"])
1967
+ ).addOption(
1968
+ new Option(
1969
+ "--prompt-service-url <url>",
1970
+ "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)."
1971
+ )
1875
1972
  ).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
1876
1973
  new Option(
1877
1974
  "--local-only",
@@ -1879,6 +1976,32 @@ var bootstrapCommand = new Command("bootstrap").description(
1879
1976
  ).default(false)
1880
1977
  ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1881
1978
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
1979
+ const nodeType = opts.nodeType ? opts.nodeType : unwrap(
1980
+ await p3.select({
1981
+ message: "What kind of node are you provisioning?",
1982
+ options: [
1983
+ {
1984
+ value: "region",
1985
+ label: "Region node (civic data for one region, remote prompt-service)"
1986
+ },
1987
+ {
1988
+ value: "region-with-prompts",
1989
+ label: "Region + colocated prompt-service (team / dev)"
1990
+ },
1991
+ {
1992
+ value: "prompts-only",
1993
+ label: "Prompt-service node (central prompts deployment \u2014 future)"
1994
+ }
1995
+ ],
1996
+ initialValue: "region"
1997
+ })
1998
+ );
1999
+ if (nodeType === "prompts-only") {
2000
+ p3.cancel(
2001
+ "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."
2002
+ );
2003
+ process.exit(1);
2004
+ }
1882
2005
  const region = opts.region ? opts.region : unwrap(
1883
2006
  await p3.text({
1884
2007
  message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
@@ -2011,6 +2134,31 @@ var bootstrapCommand = new Command("bootstrap").description(
2011
2134
  );
2012
2135
  process.exit(1);
2013
2136
  }
2137
+ if (isSshSession() && await isKeychainLocked()) {
2138
+ p3.note(
2139
+ [
2140
+ "SSH session detected with a locked login keychain.",
2141
+ "macOS keychain operations require an unlocked keychain \u2014 your",
2142
+ "login password will be used once to unlock it for this session.",
2143
+ "The keychain remains unlocked until the session ends or it",
2144
+ "idle-locks. To skip and unlock manually, press Ctrl+C and run",
2145
+ "`security unlock-keychain` yourself."
2146
+ ].join("\n"),
2147
+ "Keychain unlock required"
2148
+ );
2149
+ const pw = unwrap(
2150
+ await p3.password({
2151
+ message: "macOS login password (for `security unlock-keychain`):",
2152
+ validate: (v) => v && v.length > 0 ? void 0 : "password required"
2153
+ })
2154
+ );
2155
+ const unlock = await unlockKeychain(pw);
2156
+ if (!unlock.ok) {
2157
+ p3.cancel(unlock.reason ?? "security unlock-keychain failed.");
2158
+ process.exit(1);
2159
+ }
2160
+ p3.note(`${pc2.green("\u2713")} Keychain unlocked for this session.`, "Keychain");
2161
+ }
2014
2162
  const pgsodiumKey = await loadSecret({
2015
2163
  region,
2016
2164
  account: "pgsodium-root-key",
@@ -2064,6 +2212,46 @@ var bootstrapCommand = new Command("bootstrap").description(
2064
2212
  validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2065
2213
  generate: generateDashboardPassword
2066
2214
  });
2215
+ if (nodeType === "region-with-prompts") {
2216
+ await loadSecret({
2217
+ region,
2218
+ account: "prompts-db-password",
2219
+ label: "prompt-service Postgres password",
2220
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2221
+ generate: generatePostgresPassword
2222
+ });
2223
+ await loadSecret({
2224
+ region,
2225
+ account: "prompt-service-api-key",
2226
+ label: "prompt-service HMAC API key",
2227
+ // Same URL-safe alphabet as other HMAC keys; embedded in the
2228
+ // `<region>:<key>` API_KEYS list and in Authorization headers.
2229
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2230
+ generate: generateHmacApiKey
2231
+ });
2232
+ await loadSecret({
2233
+ region,
2234
+ account: "prompt-service-admin-api-key",
2235
+ label: "prompt-service admin API key",
2236
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
2237
+ generate: generateHmacApiKey
2238
+ });
2239
+ } else {
2240
+ await loadSecret({
2241
+ region,
2242
+ account: "prompt-service-api-key",
2243
+ label: "prompt-service HMAC API key (issued by the prompts team)",
2244
+ placeholder: "paste the region-specific HMAC key",
2245
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)"
2246
+ });
2247
+ }
2248
+ const promptServiceUrl = nodeType === "region-with-prompts" ? "http://opuspopuli-prompts:3210" : opts.promptServiceUrl ?? "https://prompts.opuspopuli.org";
2249
+ if (!SAFE_URL_RE.test(promptServiceUrl)) {
2250
+ p3.cancel(
2251
+ `--prompt-service-url ${JSON.stringify(promptServiceUrl)} contains characters outside the allowed URL set`
2252
+ );
2253
+ process.exit(1);
2254
+ }
2067
2255
  let supabaseUrl;
2068
2256
  if (opts.supabaseUrl) {
2069
2257
  supabaseUrl = opts.supabaseUrl;
@@ -2112,7 +2300,7 @@ var bootstrapCommand = new Command("bootstrap").description(
2112
2300
  }
2113
2301
  const wrapperSpin = p3.spinner();
2114
2302
  wrapperSpin.start("Installing bin/op-compose wrapper\u2026");
2115
- const wrapper = await installOpComposeWrapper({ repoDir: repoPath, region });
2303
+ const wrapper = await installOpComposeWrapper({ repoDir: repoPath, region, promptServiceUrl });
2116
2304
  if (!wrapper.ok) {
2117
2305
  wrapperSpin.stop(pc2.red("\u2717 op-compose wrapper install failed."));
2118
2306
  p3.cancel(wrapper.reason ?? "op-compose wrapper install failed.");
@@ -4212,7 +4400,7 @@ async function looksLikeRegionsRepo(dir) {
4212
4400
  }
4213
4401
 
4214
4402
  // src/cli.ts
4215
- var VERSION = "0.9.1";
4403
+ var VERSION = "0.10.1";
4216
4404
  var program = new Command();
4217
4405
  program.name("create-op-node").description(
4218
4406
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."