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 +34 -1
- package/dist/cli.js +90 -25
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
1552
|
-
)
|
|
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(
|
|
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
|
|
1703
|
+
const pgsodiumKey = await loadSecret({
|
|
1686
1704
|
region,
|
|
1687
1705
|
account: "pgsodium-root-key",
|
|
1688
1706
|
label: "pgsodium master key",
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
|
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({
|
|
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(
|
|
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 ${(
|
|
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,
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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."
|