create-op-node 0.8.0 → 0.9.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/README.md CHANGED
@@ -557,6 +557,42 @@ Releases are automated via [release-please](https://github.com/googleapis/releas
557
557
 
558
558
  No manual version bumps, no manual tagging. To trigger a release with no functional changes (re-publishing after a CI fix, etc.), push a commit with `chore: trigger release` — release-please will skip it but still update the PR.
559
559
 
560
+ #### One-time PAT setup
561
+
562
+ GitHub deliberately doesn't fire workflows on branches or tags pushed by
563
+ `GITHUB_TOKEN`. Without a PAT, two things bite every release:
564
+
565
+ - The release PR opens but its CI run never starts (sits in
566
+ *Expected — Waiting for status to be reported*). You can't merge.
567
+ - After you eventually merge, `publish.yml`'s tag-push trigger doesn't
568
+ fire either, so npm never gets the new version.
569
+
570
+ Both go away when release-please uses a PAT instead. One-time setup:
571
+
572
+ 1. **Create a fine-grained PAT** at
573
+ [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new):
574
+ - *Resource owner*: **OpusPopuli** (not your personal account —
575
+ fine-grained PATs are scoped at creation and can't be moved)
576
+ - *Expiration*: your call (90 days for safety; you'll be reminded
577
+ when it's near expiry)
578
+ - *Repository access*: **Only select repositories** →
579
+ `create-op-node`
580
+ - *Repository permissions*:
581
+ - **Contents**: Read and write
582
+ - **Pull requests**: Read and write
583
+ - **Workflows**: Read and write *(release-please updates the
584
+ version line in `release-please.yml`'s manifest output;
585
+ without this permission the action fails to write)*
586
+ 2. **Add it as a repo secret**: repo → Settings → Secrets and
587
+ variables → Actions → New repository secret →
588
+ `RELEASE_PLEASE_TOKEN`, paste the PAT value.
589
+ 3. **Done.** Next release-please run uses the PAT, CI runs normally on
590
+ the release PR, and `publish.yml` fires automatically on the tag.
591
+
592
+ The workflow falls back to `GITHUB_TOKEN` when the secret isn't
593
+ present, so it doesn't break in repos that haven't done the PAT setup
594
+ yet — it just goes back to needing the manual workarounds.
595
+
560
596
  ## License
561
597
 
562
598
  [AGPL-3.0-or-later](./LICENSE). The Opus Populi platform code is AGPL-3.0 + dual commercial; this CLI inherits the AGPL-3.0 terms.
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import pc2 from 'picocolors';
4
4
  import * as p3 from '@clack/prompts';
5
5
  import { execa } from 'execa';
6
6
  import { Octokit } from '@octokit/rest';
7
- import { randomBytes } from 'crypto';
7
+ import { randomBytes, createHmac } from 'crypto';
8
8
  import { join, resolve, dirname } from 'path';
9
9
  import { mkdir, writeFile, access, rm, chmod } from 'fs/promises';
10
10
  import { homedir } from 'os';
@@ -230,9 +230,17 @@ var SERVICE_PREFIX = "org.opuspopuli";
230
230
  function serviceFor(region) {
231
231
  return `${SERVICE_PREFIX}.${region}`;
232
232
  }
233
+ var FRIENDLY = {
234
+ "pgsodium-root-key": "pgsodium root key",
235
+ "tunnel-token": "Cloudflare Tunnel token",
236
+ "postgres-password": "Postgres password",
237
+ "jwt-secret": "JWT signing secret",
238
+ "supabase-anon-key": "Supabase anon key",
239
+ "supabase-service-role-key": "Supabase service role key",
240
+ "dashboard-password": "Supabase Studio dashboard password"
241
+ };
233
242
  function labelFor(coords) {
234
- const friendly = coords.account === "pgsodium-root-key" ? "pgsodium root key" : "Cloudflare Tunnel token";
235
- return `Opus Populi (${coords.region}) \u2014 ${friendly}`;
243
+ return `Opus Populi (${coords.region}) \u2014 ${FRIENDLY[coords.account]}`;
236
244
  }
237
245
  async function detectKeychain() {
238
246
  const res = await safeExeca("security", ["-h"]);
@@ -430,6 +438,50 @@ async function openPullRequest(input) {
430
438
  function generatePgsodiumRootKey() {
431
439
  return randomBytes(32).toString("hex");
432
440
  }
441
+ function base64url(input) {
442
+ const buf = typeof input === "string" ? Buffer.from(input) : input;
443
+ return buf.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
444
+ }
445
+ function generatePostgresPassword() {
446
+ return base64url(randomBytes(32));
447
+ }
448
+ function generateDashboardPassword() {
449
+ return base64url(randomBytes(24));
450
+ }
451
+ function generateJwtSecret() {
452
+ return randomBytes(48).toString("base64");
453
+ }
454
+ function verifySupabaseJwt(token, secret) {
455
+ const parts = token.split(".");
456
+ if (parts.length !== 3) return false;
457
+ const [h, p7, sig] = parts;
458
+ const expected = base64url(createHmac("sha256", secret).update(`${h}.${p7}`).digest());
459
+ if (sig.length !== expected.length) return false;
460
+ let mismatch = 0;
461
+ for (let i = 0; i < sig.length; i++) {
462
+ mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
463
+ }
464
+ return mismatch === 0;
465
+ }
466
+ function signSupabaseJwt(input) {
467
+ const iat = input.issuedAtSeconds ?? 17e8;
468
+ const ttl = input.ttlSeconds ?? 10 * 365 * 24 * 60 * 60;
469
+ const issuer = input.issuer ?? "supabase";
470
+ const header = { alg: "HS256", typ: "JWT" };
471
+ const payload = {
472
+ role: input.role,
473
+ iss: issuer,
474
+ iat,
475
+ exp: iat + ttl
476
+ };
477
+ const encodedHeader = base64url(JSON.stringify(header));
478
+ const encodedPayload = base64url(JSON.stringify(payload));
479
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
480
+ const signature = base64url(
481
+ createHmac("sha256", input.secret).update(signingInput).digest()
482
+ );
483
+ return `${signingInput}.${signature}`;
484
+ }
433
485
  function renderProdTfvars(input) {
434
486
  const project = input.project ?? "opuspopuli";
435
487
  const api = input.apiSubdomain ?? "api";
@@ -1042,6 +1094,9 @@ async function waitForApplyAndFetchTunnelToken(input) {
1042
1094
  var PGSODIUM_KEY_RE = /^[a-f0-9]{64}$/;
1043
1095
  var TUNNEL_TOKEN_RE = /^[A-Za-z0-9_\-.=]+$/;
1044
1096
  var SAFE_PATH_RE = /^[A-Za-z0-9_\-./ ]+$/;
1097
+ var SAFE_LAUNCHCTL_VALUE_RE = /^[A-Za-z0-9+/=._-]+$/;
1098
+ var URL_SAFE_PASSWORD_RE = /^[A-Za-z0-9_-]+$/;
1099
+ var SAFE_URL_RE = /^[A-Za-z0-9:/_.\-]+$/;
1045
1100
  var VERIFY_NETWORK_TIMEOUT_MS = 1e4;
1046
1101
  var BODY_PREVIEW_MAX = 200;
1047
1102
 
@@ -1216,11 +1271,36 @@ function renderLaunchAgentPlist(input) {
1216
1271
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
1217
1272
  );
1218
1273
  }
1274
+ const supabaseFields = [
1275
+ ["postgresPassword", input.postgresPassword],
1276
+ ["jwtSecret", input.jwtSecret],
1277
+ ["supabaseAnonKey", input.supabaseAnonKey],
1278
+ ["supabaseServiceRoleKey", input.supabaseServiceRoleKey],
1279
+ ["dashboardPassword", input.dashboardPassword]
1280
+ ];
1281
+ for (const [name, value] of supabaseFields) {
1282
+ if (value !== void 0 && !SAFE_LAUNCHCTL_VALUE_RE.test(value)) {
1283
+ throw new Error(
1284
+ `${String(name)} contains characters not allowed in a launchd setenv value`
1285
+ );
1286
+ }
1287
+ }
1288
+ if (input.supabaseUrl !== void 0 && !SAFE_URL_RE.test(input.supabaseUrl)) {
1289
+ throw new Error(
1290
+ `supabaseUrl ${JSON.stringify(input.supabaseUrl)} contains characters not allowed in a launchd setenv value`
1291
+ );
1292
+ }
1219
1293
  const setenvLines = [
1220
1294
  `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
1221
1295
  ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
1222
1296
  ...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
1223
- ...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : []
1297
+ ...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : [],
1298
+ ...input.postgresPassword !== void 0 ? [`launchctl setenv POSTGRES_PASSWORD "${input.postgresPassword}"`] : [],
1299
+ ...input.jwtSecret !== void 0 ? [`launchctl setenv JWT_SECRET "${input.jwtSecret}"`] : [],
1300
+ ...input.supabaseAnonKey !== void 0 ? [`launchctl setenv SUPABASE_ANON_KEY "${input.supabaseAnonKey}"`] : [],
1301
+ ...input.supabaseServiceRoleKey !== void 0 ? [`launchctl setenv SUPABASE_SERVICE_ROLE_KEY "${input.supabaseServiceRoleKey}"`] : [],
1302
+ ...input.dashboardPassword !== void 0 ? [`launchctl setenv DASHBOARD_PASSWORD "${input.dashboardPassword}"`] : [],
1303
+ ...input.supabaseUrl !== void 0 ? [`launchctl setenv SUPABASE_URL "${input.supabaseUrl}"`] : []
1224
1304
  ];
1225
1305
  const command = setenvLines.join("; ");
1226
1306
  return [
@@ -1277,7 +1357,13 @@ async function setupLaunchAgent(input) {
1277
1357
  keyFilePath: paths.keyFile,
1278
1358
  ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {},
1279
1359
  ...input.llmModel !== void 0 ? { llmModel: input.llmModel } : {},
1280
- ...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {}
1360
+ ...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {},
1361
+ ...input.postgresPassword !== void 0 ? { postgresPassword: input.postgresPassword } : {},
1362
+ ...input.jwtSecret !== void 0 ? { jwtSecret: input.jwtSecret } : {},
1363
+ ...input.supabaseAnonKey !== void 0 ? { supabaseAnonKey: input.supabaseAnonKey } : {},
1364
+ ...input.supabaseServiceRoleKey !== void 0 ? { supabaseServiceRoleKey: input.supabaseServiceRoleKey } : {},
1365
+ ...input.dashboardPassword !== void 0 ? { dashboardPassword: input.dashboardPassword } : {},
1366
+ ...input.supabaseUrl !== void 0 ? { supabaseUrl: input.supabaseUrl } : {}
1281
1367
  });
1282
1368
  } catch (err) {
1283
1369
  return { ok: false, paths, step: "plist", reason: err.message };
@@ -1685,6 +1771,11 @@ var bootstrapCommand = new Command("bootstrap").description(
1685
1771
  "--embedding-model <model>",
1686
1772
  `Ollama embedding model. Default: ${DEFAULT_EMBEDDING_MODEL}. Only takes effect when the knowledge service runs with EMBEDDINGS_PROVIDER=ollama (otherwise embeddings are computed in-process via xenova).`
1687
1773
  )
1774
+ ).addOption(
1775
+ new Option(
1776
+ "--supabase-url <url>",
1777
+ "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
+ )
1688
1779
  ).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
1689
1780
  new Option(
1690
1781
  "--local-only",
@@ -1838,6 +1929,65 @@ var bootstrapCommand = new Command("bootstrap").description(
1838
1929
  placeholder: "JWT-style base64url string from `terraform output tunnel_token`",
1839
1930
  validate: (v) => TUNNEL_TOKEN_RE.test(v) ? void 0 : "tunnel token must be a base64-url JWT"
1840
1931
  });
1932
+ const postgresPassword = await loadSecret({
1933
+ region,
1934
+ account: "postgres-password",
1935
+ label: "Postgres password",
1936
+ // URL-safe alphabet required — value lands in postgres:// URIs where
1937
+ // `+`/`/`/`=` cause parser ambiguity in gotrue/postgrest/storage.
1938
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
1939
+ generate: generatePostgresPassword
1940
+ });
1941
+ const jwtSecret = await loadSecret({
1942
+ region,
1943
+ account: "jwt-secret",
1944
+ label: "JWT signing secret",
1945
+ validate: (v) => SAFE_LAUNCHCTL_VALUE_RE.test(v) && v.length >= 32 ? void 0 : "must be \u226532 base64 characters",
1946
+ generate: generateJwtSecret
1947
+ });
1948
+ const supabaseAnonKey = await loadSecret({
1949
+ region,
1950
+ account: "supabase-anon-key",
1951
+ label: "Supabase anon key",
1952
+ validate: (v) => SAFE_LAUNCHCTL_VALUE_RE.test(v) && verifySupabaseJwt(v, jwtSecret) ? void 0 : "must be a base64url JWT signed by the current JWT_SECRET",
1953
+ generate: () => signSupabaseJwt({ role: "anon", secret: jwtSecret })
1954
+ });
1955
+ const supabaseServiceRoleKey = await loadSecret({
1956
+ region,
1957
+ account: "supabase-service-role-key",
1958
+ label: "Supabase service role key",
1959
+ validate: (v) => SAFE_LAUNCHCTL_VALUE_RE.test(v) && verifySupabaseJwt(v, jwtSecret) ? void 0 : "must be a base64url JWT signed by the current JWT_SECRET",
1960
+ generate: () => signSupabaseJwt({ role: "service_role", secret: jwtSecret })
1961
+ });
1962
+ const dashboardPassword = await loadSecret({
1963
+ region,
1964
+ account: "dashboard-password",
1965
+ label: "Supabase Studio dashboard password",
1966
+ // URL-safe alphabet required — value lands in basic-auth headers via
1967
+ // kong's declarative config. Avoid `+`/`/`/`=` for the same reasons.
1968
+ validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
1969
+ generate: generateDashboardPassword
1970
+ });
1971
+ let supabaseUrl;
1972
+ if (opts.supabaseUrl) {
1973
+ supabaseUrl = opts.supabaseUrl;
1974
+ } else if (opts.localOnly) {
1975
+ supabaseUrl = "http://localhost:8000";
1976
+ } else {
1977
+ supabaseUrl = unwrap(
1978
+ await p3.text({
1979
+ message: "Public-facing Supabase URL (what browsers + microservices use to reach kong)?",
1980
+ placeholder: "https://supabase.civicfeed.tx",
1981
+ validate: (v) => !v ? "required" : SAFE_URL_RE.test(v) ? void 0 : "contains characters outside the allowed URL set"
1982
+ })
1983
+ );
1984
+ }
1985
+ if (!SAFE_URL_RE.test(supabaseUrl)) {
1986
+ p3.cancel(
1987
+ `--supabase-url ${JSON.stringify(supabaseUrl)} contains characters outside the allowed URL set`
1988
+ );
1989
+ process.exit(1);
1990
+ }
1841
1991
  if (!opts.skipLaunchAgent) {
1842
1992
  const laSpin = p3.spinner();
1843
1993
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
@@ -1845,7 +1995,13 @@ var bootstrapCommand = new Command("bootstrap").description(
1845
1995
  pgsodiumKey,
1846
1996
  ...tunnelToken !== void 0 ? { tunnelToken } : {},
1847
1997
  llmModel,
1848
- embeddingModel
1998
+ embeddingModel,
1999
+ postgresPassword,
2000
+ jwtSecret,
2001
+ supabaseAnonKey,
2002
+ supabaseServiceRoleKey,
2003
+ dashboardPassword,
2004
+ supabaseUrl
1849
2005
  });
1850
2006
  if (!la.ok) {
1851
2007
  laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
@@ -3936,7 +4092,7 @@ async function looksLikeRegionsRepo(dir) {
3936
4092
  }
3937
4093
 
3938
4094
  // src/cli.ts
3939
- var VERSION = "0.8.0";
4095
+ var VERSION = "0.9.0";
3940
4096
  var program = new Command();
3941
4097
  program.name("create-op-node").description(
3942
4098
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."