create-op-node 0.3.0 → 0.4.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
@@ -22,7 +22,40 @@ Then on the Mac Studio itself:
22
22
  npx create-op-node bootstrap
23
23
  ```
24
24
 
25
- 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 pull && up -d` brings the whole stack online. Health-check loop waits until all 10 containers are `(healthy)`.
25
+ 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)`.
26
+
27
+ ### Local-only mode (no Cloudflare)
28
+
29
+ ```bash
30
+ npx create-op-node bootstrap --region us-ca --local-only
31
+ ```
32
+
33
+ Brings the Studio up for local dev / testing — frontend on your laptop
34
+ hits the Studio over Tailscale, no public exposure. Differences from
35
+ the standard run:
36
+
37
+ - **No Tunnel token required.** `init` is unnecessary; if the pgsodium
38
+ key isn't in Keychain, bootstrap generates one inline and persists it.
39
+ - **`cloudflared` stays down.** It's gated behind the `public` compose
40
+ profile, which `--local-only` doesn't activate. Bootstrap also evicts
41
+ any leftover cloudflared from a prior public run so it doesn't strand
42
+ in `compose ps`.
43
+ - **Backup stack skipped by default.** `docker-compose-backup.yml`
44
+ isn't loaded; pass `--compose-file docker-compose-backup.yml` to
45
+ include it explicitly.
46
+ - **LaunchAgent omits `TUNNEL_TOKEN`.** Only `PGSODIUM_ROOT_KEY` is
47
+ exported into the launchd session.
48
+ - **Outro tells you to use Tailscale**, not `npx create-op-node verify`.
49
+
50
+ When you're ready to go public, re-run `bootstrap` without `--local-only`
51
+ and the same Studio promotes to the full production-shaped deploy.
52
+
53
+ > **Template version**: this mode depends on the `opuspopuli-node`
54
+ > template having `profiles: [public]` on its cloudflared service. If
55
+ > you cloned the template before that landed, refresh your fork
56
+ > (or recreate from template) before using `--local-only` — otherwise
57
+ > cloudflared starts regardless and will restart-loop without a
58
+ > TUNNEL_TOKEN.
26
59
 
27
60
  > **Secret transport between laptop and Studio**
28
61
  >
package/dist/cli.js CHANGED
@@ -1110,7 +1110,7 @@ async function writePgsodiumKeyFile(key, keyFile) {
1110
1110
  }
1111
1111
  }
1112
1112
  function renderLaunchAgentPlist(input) {
1113
- if (!TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1113
+ if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1114
1114
  throw new Error("Tunnel token contains characters outside the expected base64-url set");
1115
1115
  }
1116
1116
  if (!SAFE_PATH_RE.test(input.keyFilePath)) {
@@ -1118,7 +1118,11 @@ function renderLaunchAgentPlist(input) {
1118
1118
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
1119
1119
  );
1120
1120
  }
1121
- const command = `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"; launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`;
1121
+ const setenvLines = [
1122
+ `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
1123
+ ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : []
1124
+ ];
1125
+ const command = setenvLines.join("; ");
1122
1126
  return [
1123
1127
  '<?xml version="1.0" encoding="UTF-8"?>',
1124
1128
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -1171,7 +1175,7 @@ async function setupLaunchAgent(input) {
1171
1175
  try {
1172
1176
  plistContent = renderLaunchAgentPlist({
1173
1177
  keyFilePath: paths.keyFile,
1174
- tunnelToken: input.tunnelToken
1178
+ ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {}
1175
1179
  });
1176
1180
  } catch (err) {
1177
1181
  return { ok: false, paths, step: "plist", reason: err.message };
@@ -1254,7 +1258,8 @@ async function loginToGhcr() {
1254
1258
  function composeArgs(opts, sub) {
1255
1259
  const files = opts.files.flatMap((f) => ["-f", f]);
1256
1260
  const env = opts.envFile ? ["--env-file", opts.envFile] : [];
1257
- return ["compose", ...files, ...env, ...sub];
1261
+ const profiles = (opts.profiles ?? []).flatMap((pr) => ["--profile", pr]);
1262
+ return ["compose", ...files, ...env, ...profiles, ...sub];
1258
1263
  }
1259
1264
  async function composePull(opts) {
1260
1265
  const res = await safeExeca("docker", composeArgs(opts, ["pull"]));
@@ -1264,6 +1269,10 @@ async function composeUp(opts) {
1264
1269
  const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
1265
1270
  return result(res, "compose up");
1266
1271
  }
1272
+ async function composeRemoveService(opts, service) {
1273
+ const res = await safeExeca("docker", composeArgs(opts, ["rm", "-sfv", service]));
1274
+ return result(res, `compose rm ${service}`);
1275
+ }
1267
1276
  async function composeDown(opts) {
1268
1277
  const flags = ["down"];
1269
1278
  if (opts.wipeVolumes) flags.push("-v");
@@ -1543,19 +1552,26 @@ async function locateOrCloneRepo(input) {
1543
1552
  }
1544
1553
 
1545
1554
  // src/commands/bootstrap.ts
1555
+ var PUBLIC_PROFILES = ["public"];
1556
+ var LOCAL_PROFILES = [];
1546
1557
  var bootstrapCommand = new Command("bootstrap").description(
1547
1558
  "Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
1548
1559
  ).addOption(new Option("--region <slug>", "Region label set during init (e.g. us-ca)")).addOption(new Option("--owner <owner>", "GitHub owner for the node repo").default("OpusPopuli")).addOption(new Option("--repo-dir <path>", "Explicit path to a checked-out node repo (overrides cwd + clone)")).addOption(
1549
1560
  new Option(
1550
1561
  "--compose-file <path>",
1551
- "Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml + docker-compose-backup.yml"
1552
- ).default(["docker-compose-prod.yml", "docker-compose-backup.yml"])
1562
+ "Repeatable. Compose file relative to repo root. Default: prod + backup (production), prod only (--local-only)."
1563
+ )
1553
1564
  ).addOption(new Option("--env-file <path>", "Compose --env-file. Default: .env.production")).addOption(new Option("--skip-brew", "Skip the Homebrew package install pass").default(false)).addOption(
1554
1565
  new Option(
1555
1566
  "--skip-launch-agent",
1556
1567
  "Skip the LaunchAgent setup (assumes one is already in place)"
1557
1568
  ).default(false)
1558
- ).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1569
+ ).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
1570
+ new Option(
1571
+ "--local-only",
1572
+ "Run for local dev / testing: no Tunnel token required, cloudflared stays down. Auto-generates the pgsodium key if not in Keychain (init unnecessary)."
1573
+ ).default(false)
1574
+ ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1559
1575
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
1560
1576
  const region = opts.region ? opts.region : unwrap(
1561
1577
  await p3.text({
@@ -1566,6 +1582,8 @@ var bootstrapCommand = new Command("bootstrap").description(
1566
1582
  );
1567
1583
  const owner = opts.owner ?? "OpusPopuli";
1568
1584
  const repoName = `opuspopuli-node-${region}`;
1585
+ const composeFileDefault = opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
1586
+ const composeFile = opts.composeFile ?? composeFileDefault;
1569
1587
  const sysSpin = p3.spinner();
1570
1588
  sysSpin.start("Inspecting macOS\u2026");
1571
1589
  const snap = await inspectSystem();
@@ -1682,14 +1700,14 @@ var bootstrapCommand = new Command("bootstrap").description(
1682
1700
  );
1683
1701
  process.exit(1);
1684
1702
  }
1685
- const pgsodiumKey = await loadOrPromptSecret({
1703
+ const pgsodiumKey = await loadSecret({
1686
1704
  region,
1687
1705
  account: "pgsodium-root-key",
1688
1706
  label: "pgsodium master key",
1689
- placeholder: "64 lowercase hex characters",
1690
- validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters"
1707
+ validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters",
1708
+ ...opts.localOnly ? { generate: generatePgsodiumRootKey } : {}
1691
1709
  });
1692
- const tunnelToken = await loadOrPromptSecret({
1710
+ const tunnelToken = opts.localOnly ? void 0 : await loadSecret({
1693
1711
  region,
1694
1712
  account: "tunnel-token",
1695
1713
  label: "Cloudflare Tunnel token",
@@ -1699,13 +1717,20 @@ var bootstrapCommand = new Command("bootstrap").description(
1699
1717
  if (!opts.skipLaunchAgent) {
1700
1718
  const laSpin = p3.spinner();
1701
1719
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
1702
- const la = await setupLaunchAgent({ pgsodiumKey, tunnelToken });
1720
+ const la = await setupLaunchAgent({
1721
+ pgsodiumKey,
1722
+ ...tunnelToken !== void 0 ? { tunnelToken } : {}
1723
+ });
1703
1724
  if (!la.ok) {
1704
1725
  laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
1705
1726
  p3.cancel(la.reason ?? "LaunchAgent setup failed.");
1706
1727
  process.exit(1);
1707
1728
  }
1708
- laSpin.stop(pc2.green(`\u2713 LaunchAgent loaded (${la.paths.plistFile}).`));
1729
+ laSpin.stop(
1730
+ pc2.green(
1731
+ `\u2713 LaunchAgent loaded (${la.paths.plistFile})${opts.localOnly ? " \u2014 local-only mode, no TUNNEL_TOKEN set" : ""}.`
1732
+ )
1733
+ );
1709
1734
  }
1710
1735
  const ghcrSpin = p3.spinner();
1711
1736
  ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
@@ -1756,19 +1781,32 @@ var bootstrapCommand = new Command("bootstrap").description(
1756
1781
  }
1757
1782
  }
1758
1783
  if (opts.skipStack) {
1784
+ const profileFlag = opts.localOnly ? "" : "--profile public ";
1759
1785
  p3.outro(
1760
1786
  pc2.cyan(
1761
- `Stack-up skipped. Run \`docker compose -f ${(opts.composeFile ?? ["docker-compose-prod.yml"]).join(" -f ")} pull && up -d\` from ${repoPath} when ready.`
1787
+ `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.`
1762
1788
  )
1763
1789
  );
1764
1790
  return;
1765
1791
  }
1766
- const composeFiles = resolveComposeFiles(repoPath, opts.composeFile);
1792
+ const composeFiles = resolveComposeFiles(repoPath, composeFile);
1767
1793
  const composeOpts = {
1768
1794
  files: composeFiles,
1769
1795
  cwd: repoPath,
1770
- ...opts.envFile ? { envFile: opts.envFile } : {}
1796
+ ...opts.envFile ? { envFile: opts.envFile } : {},
1797
+ profiles: opts.localOnly ? LOCAL_PROFILES : PUBLIC_PROFILES
1771
1798
  };
1799
+ if (opts.localOnly) {
1800
+ const evict = p3.spinner();
1801
+ evict.start("Evicting any cloudflared from a prior public bootstrap\u2026");
1802
+ const rm2 = await composeRemoveService(
1803
+ { ...composeOpts, profiles: PUBLIC_PROFILES },
1804
+ "cloudflared"
1805
+ );
1806
+ evict.stop(
1807
+ rm2.ok ? pc2.green("\u2713 cloudflared not present (or removed).") : pc2.yellow(`\u26A0 cloudflared eviction reported: ${rm2.reason ?? "unknown"} \u2014 continuing.`)
1808
+ );
1809
+ }
1772
1810
  const pullSpin = p3.spinner();
1773
1811
  pullSpin.start("Pulling images from ghcr.io\u2026");
1774
1812
  const pull = await composePull(composeOpts);
@@ -1799,11 +1837,23 @@ var bootstrapCommand = new Command("bootstrap").description(
1799
1837
  switch (outcome.kind) {
1800
1838
  case "healthy":
1801
1839
  healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
1802
- p3.outro(
1803
- pc2.cyan(
1804
- `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1805
- )
1806
- );
1840
+ if (opts.localOnly) {
1841
+ p3.outro(
1842
+ pc2.cyan(
1843
+ [
1844
+ `Region ${region} is up locally (no public Cloudflare Tunnel \u2014 cloudflared stays down).`,
1845
+ `Access from your laptop over Tailscale at this Studio's tailnet IP.`,
1846
+ `When you're ready to expose publicly, re-run \`bootstrap\` without --local-only.`
1847
+ ].join("\n")
1848
+ )
1849
+ );
1850
+ } else {
1851
+ p3.outro(
1852
+ pc2.cyan(
1853
+ `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1854
+ )
1855
+ );
1856
+ }
1807
1857
  return;
1808
1858
  case "unhealthy":
1809
1859
  healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
@@ -1930,7 +1980,7 @@ async function promptTailscaleSignin() {
1930
1980
  );
1931
1981
  unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
1932
1982
  }
1933
- async function loadOrPromptSecret(input) {
1983
+ async function loadSecret(input) {
1934
1984
  const coords = { region: input.region, account: input.account };
1935
1985
  const spin = p3.spinner();
1936
1986
  spin.start(`Reading ${input.label} from Keychain\u2026`);
@@ -1940,8 +1990,20 @@ async function loadOrPromptSecret(input) {
1940
1990
  return existing;
1941
1991
  }
1942
1992
  spin.stop(
1943
- existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed validation \u2014 will re-prompt.`) : pc2.dim(`\xB7 ${input.label} not in Keychain \u2014 will prompt.`)
1993
+ existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed format validation \u2014 will replace.`) : pc2.dim(`\xB7 ${input.label} not in Keychain.`)
1944
1994
  );
1995
+ if (input.generate) {
1996
+ const fresh = input.generate();
1997
+ const save2 = await saveSecret(coords, fresh);
1998
+ if (!save2.written) {
1999
+ p3.cancel(
2000
+ `Generated a fresh ${input.label} but couldn't persist it to Keychain: ${save2.reason ?? "unknown"}. Continuing would risk silent key rotation on the next re-run. Resolve the Keychain access (re-grant via Keychain Access.app or check the security CLI) and re-run.`
2001
+ );
2002
+ process.exit(1);
2003
+ }
2004
+ p3.note(`${pc2.green("\u2713")} Generated fresh ${input.label} and stored in Keychain.`, "Keychain");
2005
+ return fresh;
2006
+ }
1945
2007
  const pasted = unwrap(
1946
2008
  await p3.password({
1947
2009
  message: `Paste the ${input.label} for region ${input.region}:`,
@@ -1950,7 +2012,10 @@ async function loadOrPromptSecret(input) {
1950
2012
  );
1951
2013
  const save = await saveSecret(coords, pasted);
1952
2014
  if (!save.written) {
1953
- p3.note(`${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. Continuing anyway.`, "Keychain");
2015
+ p3.note(
2016
+ `${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. You'll need to paste it again next run.`,
2017
+ "Keychain"
2018
+ );
1954
2019
  } else {
1955
2020
  p3.note(`${pc2.green("\u2713")} Stored ${input.label} in Keychain for re-runs.`, "Keychain");
1956
2021
  }
@@ -3663,7 +3728,7 @@ async function looksLikeRegionsRepo(dir) {
3663
3728
  }
3664
3729
 
3665
3730
  // src/cli.ts
3666
- var VERSION = "0.3.0";
3731
+ var VERSION = "0.4.0";
3667
3732
  var program = new Command();
3668
3733
  program.name("create-op-node").description(
3669
3734
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."