create-op-node 0.2.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 +103 -7
- package/dist/cli.js +215 -152
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -225,81 +225,75 @@ async function safeExeca(cmd, args, options) {
|
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
// src/lib/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return { installed: false, signedIn: false };
|
|
233
|
-
}
|
|
234
|
-
const installed = true;
|
|
235
|
-
const whoami = await safeExeca("op", ["whoami", "--format=json"]);
|
|
236
|
-
if (whoami === null || whoami.exitCode !== 0) {
|
|
237
|
-
return { installed, signedIn: false };
|
|
238
|
-
}
|
|
239
|
-
let email;
|
|
240
|
-
try {
|
|
241
|
-
const parsed = JSON.parse(whoami.stdout);
|
|
242
|
-
email = parsed.email;
|
|
243
|
-
} catch {
|
|
244
|
-
}
|
|
245
|
-
return {
|
|
246
|
-
installed,
|
|
247
|
-
signedIn: true,
|
|
248
|
-
...email ? { email } : {}
|
|
249
|
-
};
|
|
228
|
+
// src/lib/keychain.ts
|
|
229
|
+
var SERVICE_PREFIX = "org.opuspopuli";
|
|
230
|
+
function serviceFor(region) {
|
|
231
|
+
return `${SERVICE_PREFIX}.${region}`;
|
|
250
232
|
}
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
233
|
+
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}`;
|
|
236
|
+
}
|
|
237
|
+
async function detectKeychain() {
|
|
238
|
+
const res = await safeExeca("security", ["-h"]);
|
|
239
|
+
if (res === null) {
|
|
255
240
|
return {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
reason: "`op` CLI not installed"
|
|
241
|
+
available: false,
|
|
242
|
+
reason: "`security` CLI not on PATH (Keychain requires macOS)"
|
|
259
243
|
};
|
|
260
244
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
alreadyExisted: true,
|
|
273
|
-
reason: `op item edit failed: ${edit?.stderr || edit?.stdout || "`op` not installed"}`
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
return { written: true, alreadyExisted: true };
|
|
277
|
-
}
|
|
278
|
-
const create = await safeExeca("op", [
|
|
279
|
-
"item",
|
|
280
|
-
"create",
|
|
281
|
-
"--category",
|
|
282
|
-
"Secure Note",
|
|
283
|
-
"--title",
|
|
284
|
-
input.title,
|
|
285
|
-
...vaultArg,
|
|
286
|
-
`notesPlain=${input.value}`
|
|
245
|
+
return { available: true };
|
|
246
|
+
}
|
|
247
|
+
async function saveSecret(coords, value) {
|
|
248
|
+
const service = serviceFor(coords.region);
|
|
249
|
+
const label = labelFor(coords);
|
|
250
|
+
const existing = await safeExeca("security", [
|
|
251
|
+
"find-generic-password",
|
|
252
|
+
"-s",
|
|
253
|
+
service,
|
|
254
|
+
"-a",
|
|
255
|
+
coords.account
|
|
287
256
|
]);
|
|
288
|
-
|
|
257
|
+
const updated = existing !== null && existing.exitCode === 0;
|
|
258
|
+
const res = await safeExeca("security", [
|
|
259
|
+
"add-generic-password",
|
|
260
|
+
"-U",
|
|
261
|
+
// upsert
|
|
262
|
+
"-s",
|
|
263
|
+
service,
|
|
264
|
+
"-a",
|
|
265
|
+
coords.account,
|
|
266
|
+
"-l",
|
|
267
|
+
label,
|
|
268
|
+
"-D",
|
|
269
|
+
"Opus Populi secret",
|
|
270
|
+
"-w",
|
|
271
|
+
value
|
|
272
|
+
// argv-exposure caveat, see file header
|
|
273
|
+
]);
|
|
274
|
+
if (res === null) {
|
|
275
|
+
return { written: false, updated: false, reason: "`security` CLI not on PATH" };
|
|
276
|
+
}
|
|
277
|
+
if (res.exitCode !== 0) {
|
|
289
278
|
return {
|
|
290
279
|
written: false,
|
|
291
|
-
|
|
292
|
-
reason: `
|
|
280
|
+
updated,
|
|
281
|
+
reason: `security add-generic-password failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
|
|
293
282
|
};
|
|
294
283
|
}
|
|
295
|
-
return { written: true,
|
|
296
|
-
}
|
|
297
|
-
async function
|
|
298
|
-
const
|
|
299
|
-
const res = await safeExeca(
|
|
300
|
-
"
|
|
301
|
-
|
|
302
|
-
|
|
284
|
+
return { written: true, updated };
|
|
285
|
+
}
|
|
286
|
+
async function readSecret(coords) {
|
|
287
|
+
const service = serviceFor(coords.region);
|
|
288
|
+
const res = await safeExeca("security", [
|
|
289
|
+
"find-generic-password",
|
|
290
|
+
"-s",
|
|
291
|
+
service,
|
|
292
|
+
"-a",
|
|
293
|
+
coords.account,
|
|
294
|
+
"-w"
|
|
295
|
+
// print the password to stdout (final flag = read mode)
|
|
296
|
+
]);
|
|
303
297
|
if (res === null || res.exitCode !== 0) return null;
|
|
304
298
|
const value = res.stdout.trim();
|
|
305
299
|
return value.length > 0 ? value : null;
|
|
@@ -532,10 +526,10 @@ var initCommand = new Command("init").description(
|
|
|
532
526
|
new Option("--template <owner/repo>", "Template repo to clone from").default(
|
|
533
527
|
"OpusPopuli/opuspopuli-node"
|
|
534
528
|
)
|
|
535
|
-
).addOption(new Option("--project <name>", "tfvars project prefix").default("opuspopuli")).addOption(new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")).addOption(new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")).addOption(new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")).addOption(new Option("--tf-token <token>", "Terraform Cloud user/team token").env("TF_API_TOKEN")).addOption(new Option("--tf-org <org>", "Terraform Cloud organization").env("TF_CLOUD_ORGANIZATION")).addOption(new Option("--gh-token <token>", "GitHub Personal Access Token (else gh CLI)").env("GH_TOKEN")).addOption(
|
|
529
|
+
).addOption(new Option("--project <name>", "tfvars project prefix").default("opuspopuli")).addOption(new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")).addOption(new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")).addOption(new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")).addOption(new Option("--tf-token <token>", "Terraform Cloud user/team token").env("TF_API_TOKEN")).addOption(new Option("--tf-org <org>", "Terraform Cloud organization").env("TF_CLOUD_ORGANIZATION")).addOption(new Option("--gh-token <token>", "GitHub Personal Access Token (else gh CLI)").env("GH_TOKEN")).addOption(
|
|
536
530
|
new Option(
|
|
537
531
|
"--overwrite",
|
|
538
|
-
"Overwrite existing
|
|
532
|
+
"Overwrite existing Keychain items (pgsodium key, Tunnel token) on a re-run"
|
|
539
533
|
).default(false)
|
|
540
534
|
).addOption(
|
|
541
535
|
new Option(
|
|
@@ -556,7 +550,7 @@ var initCommand = new Command("init").description(
|
|
|
556
550
|
);
|
|
557
551
|
const region = opts.region ? opts.region : unwrap(
|
|
558
552
|
await p3.text({
|
|
559
|
-
message: "Short region label (used as
|
|
553
|
+
message: "Short region label (used as the Keychain service suffix + R2 prefix)?",
|
|
560
554
|
placeholder: "us-ca",
|
|
561
555
|
validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
562
556
|
})
|
|
@@ -637,9 +631,10 @@ var initCommand = new Command("init").description(
|
|
|
637
631
|
"github"
|
|
638
632
|
);
|
|
639
633
|
}
|
|
640
|
-
const
|
|
641
|
-
const
|
|
642
|
-
|
|
634
|
+
const keychain = await detectKeychain();
|
|
635
|
+
const kcNote = keychain.available ? `\u2713 macOS Keychain available \u2014 pgsodium key + Tunnel token will be saved to your login keychain.
|
|
636
|
+
` + pc2.dim("Note: items don't sync to iCloud Keychain via the security CLI.\n") + pc2.dim("On the Studio, bootstrap will read locally and prompt you to paste if missing.") : `\u26A0 ${keychain.reason ?? "Keychain unavailable"}. Secrets will be printed for manual stash.`;
|
|
637
|
+
p3.note(kcNote, "Secret store");
|
|
643
638
|
p3.note(
|
|
644
639
|
[
|
|
645
640
|
`Region label: ${pc2.cyan(region)}`,
|
|
@@ -786,40 +781,30 @@ var initCommand = new Command("init").description(
|
|
|
786
781
|
process.exit(1);
|
|
787
782
|
}
|
|
788
783
|
prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
if (
|
|
792
|
-
const existing = opts.overwrite ? null : await
|
|
784
|
+
const pgsodiumCoords = { region, account: "pgsodium-root-key" };
|
|
785
|
+
const keyLabel = `org.opuspopuli.${region}/pgsodium-root-key`;
|
|
786
|
+
if (keychain.available) {
|
|
787
|
+
const existing = opts.overwrite ? null : await readSecret(pgsodiumCoords);
|
|
793
788
|
if (existing && /^[a-f0-9]{64}$/.test(existing)) {
|
|
794
789
|
p3.note(
|
|
795
|
-
`${pc2.green("\u2713")} Re-using existing pgsodium master key from
|
|
790
|
+
`${pc2.green("\u2713")} Re-using existing pgsodium master key from Keychain (${pc2.cyan(keyLabel)}). Pass --overwrite to rotate.`,
|
|
796
791
|
"Secret"
|
|
797
792
|
);
|
|
798
793
|
} else {
|
|
799
794
|
const fresh = generatePgsodiumRootKey();
|
|
800
|
-
const r = await
|
|
801
|
-
title: keyTitle,
|
|
802
|
-
value: fresh,
|
|
803
|
-
overwrite: opts.overwrite ?? false,
|
|
804
|
-
...vaultArg
|
|
805
|
-
});
|
|
795
|
+
const r = await saveSecret(pgsodiumCoords, fresh);
|
|
806
796
|
if (r.written) {
|
|
807
797
|
p3.note(
|
|
808
|
-
`${pc2.green("\u2713")} pgsodium master key${r.
|
|
809
|
-
"Secret"
|
|
810
|
-
);
|
|
811
|
-
} else if (r.alreadyExisted) {
|
|
812
|
-
p3.note(
|
|
813
|
-
`${pc2.yellow("!")} 1Password has an item titled ${pc2.cyan(keyTitle)} but its value is NOT a 64-hex pgsodium key. Inspect it in 1Password; re-run with --overwrite to replace.`,
|
|
798
|
+
`${pc2.green("\u2713")} pgsodium master key${r.updated ? " rotated" : " saved"} to Keychain as ${pc2.cyan(keyLabel)}.`,
|
|
814
799
|
"Secret"
|
|
815
800
|
);
|
|
816
801
|
} else {
|
|
817
|
-
await printKeyForManualSave(fresh,
|
|
802
|
+
await printKeyForManualSave(fresh, keyLabel, r.reason);
|
|
818
803
|
}
|
|
819
804
|
}
|
|
820
805
|
} else {
|
|
821
806
|
const fresh = generatePgsodiumRootKey();
|
|
822
|
-
await printKeyForManualSave(fresh,
|
|
807
|
+
await printKeyForManualSave(fresh, keyLabel);
|
|
823
808
|
}
|
|
824
809
|
if (opts.skipWait) {
|
|
825
810
|
p3.outro(
|
|
@@ -870,24 +855,20 @@ var initCommand = new Command("init").description(
|
|
|
870
855
|
);
|
|
871
856
|
process.exit(1);
|
|
872
857
|
}
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
value: tunnelToken,
|
|
878
|
-
...opts.vault ? { vault: opts.vault } : {},
|
|
879
|
-
overwrite: true
|
|
880
|
-
});
|
|
858
|
+
const tunnelCoords = { region, account: "tunnel-token" };
|
|
859
|
+
const tunnelLabel = `org.opuspopuli.${region}/tunnel-token`;
|
|
860
|
+
if (keychain.available) {
|
|
861
|
+
const r = await saveSecret(tunnelCoords, tunnelToken);
|
|
881
862
|
if (r.written) {
|
|
882
863
|
p3.note(
|
|
883
|
-
`${pc2.green("\u2713")} Tunnel token saved to
|
|
864
|
+
`${pc2.green("\u2713")} Tunnel token saved to Keychain as ${pc2.cyan(tunnelLabel)}.`,
|
|
884
865
|
"Secret"
|
|
885
866
|
);
|
|
886
867
|
} else {
|
|
887
|
-
await printKeyForManualSave(tunnelToken,
|
|
868
|
+
await printKeyForManualSave(tunnelToken, tunnelLabel, r.reason);
|
|
888
869
|
}
|
|
889
870
|
} else {
|
|
890
|
-
await printKeyForManualSave(tunnelToken,
|
|
871
|
+
await printKeyForManualSave(tunnelToken, tunnelLabel);
|
|
891
872
|
}
|
|
892
873
|
p3.outro(
|
|
893
874
|
pc2.cyan(
|
|
@@ -923,21 +904,22 @@ async function collectId(preset, message) {
|
|
|
923
904
|
);
|
|
924
905
|
return v;
|
|
925
906
|
}
|
|
926
|
-
async function printKeyForManualSave(value,
|
|
907
|
+
async function printKeyForManualSave(value, label, reason) {
|
|
927
908
|
p3.note(
|
|
928
909
|
[
|
|
929
910
|
reason ? `${pc2.red("\u2717")} ${reason}` : "",
|
|
930
|
-
`Save this value to
|
|
911
|
+
`Save this value to your secret store as ${pc2.cyan(label)}:`,
|
|
931
912
|
"",
|
|
932
913
|
pc2.yellow(value),
|
|
933
914
|
"",
|
|
934
|
-
pc2.dim("It will not be shown again.")
|
|
915
|
+
pc2.dim("It will not be shown again."),
|
|
916
|
+
pc2.dim("On the Studio, bootstrap will prompt you to paste it back.")
|
|
935
917
|
].filter((line) => line.length > 0).join("\n"),
|
|
936
918
|
"Manual save"
|
|
937
919
|
);
|
|
938
920
|
unwrap(
|
|
939
921
|
await p3.confirm({
|
|
940
|
-
message: "Value stashed
|
|
922
|
+
message: "Value stashed?",
|
|
941
923
|
initialValue: true
|
|
942
924
|
})
|
|
943
925
|
);
|
|
@@ -1128,7 +1110,7 @@ async function writePgsodiumKeyFile(key, keyFile) {
|
|
|
1128
1110
|
}
|
|
1129
1111
|
}
|
|
1130
1112
|
function renderLaunchAgentPlist(input) {
|
|
1131
|
-
if (!TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
|
|
1113
|
+
if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
|
|
1132
1114
|
throw new Error("Tunnel token contains characters outside the expected base64-url set");
|
|
1133
1115
|
}
|
|
1134
1116
|
if (!SAFE_PATH_RE.test(input.keyFilePath)) {
|
|
@@ -1136,7 +1118,11 @@ function renderLaunchAgentPlist(input) {
|
|
|
1136
1118
|
`keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
|
|
1137
1119
|
);
|
|
1138
1120
|
}
|
|
1139
|
-
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("; ");
|
|
1140
1126
|
return [
|
|
1141
1127
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1142
1128
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -1189,7 +1175,7 @@ async function setupLaunchAgent(input) {
|
|
|
1189
1175
|
try {
|
|
1190
1176
|
plistContent = renderLaunchAgentPlist({
|
|
1191
1177
|
keyFilePath: paths.keyFile,
|
|
1192
|
-
tunnelToken: input.tunnelToken
|
|
1178
|
+
...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {}
|
|
1193
1179
|
});
|
|
1194
1180
|
} catch (err) {
|
|
1195
1181
|
return { ok: false, paths, step: "plist", reason: err.message };
|
|
@@ -1272,7 +1258,8 @@ async function loginToGhcr() {
|
|
|
1272
1258
|
function composeArgs(opts, sub) {
|
|
1273
1259
|
const files = opts.files.flatMap((f) => ["-f", f]);
|
|
1274
1260
|
const env = opts.envFile ? ["--env-file", opts.envFile] : [];
|
|
1275
|
-
|
|
1261
|
+
const profiles = (opts.profiles ?? []).flatMap((pr) => ["--profile", pr]);
|
|
1262
|
+
return ["compose", ...files, ...env, ...profiles, ...sub];
|
|
1276
1263
|
}
|
|
1277
1264
|
async function composePull(opts) {
|
|
1278
1265
|
const res = await safeExeca("docker", composeArgs(opts, ["pull"]));
|
|
@@ -1282,6 +1269,10 @@ async function composeUp(opts) {
|
|
|
1282
1269
|
const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
|
|
1283
1270
|
return result(res, "compose up");
|
|
1284
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
|
+
}
|
|
1285
1276
|
async function composeDown(opts) {
|
|
1286
1277
|
const flags = ["down"];
|
|
1287
1278
|
if (opts.wipeVolumes) flags.push("-v");
|
|
@@ -1561,19 +1552,26 @@ async function locateOrCloneRepo(input) {
|
|
|
1561
1552
|
}
|
|
1562
1553
|
|
|
1563
1554
|
// src/commands/bootstrap.ts
|
|
1555
|
+
var PUBLIC_PROFILES = ["public"];
|
|
1556
|
+
var LOCAL_PROFILES = [];
|
|
1564
1557
|
var bootstrapCommand = new Command("bootstrap").description(
|
|
1565
1558
|
"Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
|
|
1566
|
-
).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("--
|
|
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(
|
|
1567
1560
|
new Option(
|
|
1568
1561
|
"--compose-file <path>",
|
|
1569
|
-
"Repeatable. Compose file relative to repo root. Default:
|
|
1570
|
-
)
|
|
1562
|
+
"Repeatable. Compose file relative to repo root. Default: prod + backup (production), prod only (--local-only)."
|
|
1563
|
+
)
|
|
1571
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(
|
|
1572
1565
|
new Option(
|
|
1573
1566
|
"--skip-launch-agent",
|
|
1574
1567
|
"Skip the LaunchAgent setup (assumes one is already in place)"
|
|
1575
1568
|
).default(false)
|
|
1576
|
-
).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) => {
|
|
1577
1575
|
p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
|
|
1578
1576
|
const region = opts.region ? opts.region : unwrap(
|
|
1579
1577
|
await p3.text({
|
|
@@ -1584,6 +1582,8 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1584
1582
|
);
|
|
1585
1583
|
const owner = opts.owner ?? "OpusPopuli";
|
|
1586
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;
|
|
1587
1587
|
const sysSpin = p3.spinner();
|
|
1588
1588
|
sysSpin.start("Inspecting macOS\u2026");
|
|
1589
1589
|
const snap = await inspectSystem();
|
|
@@ -1693,47 +1693,44 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1693
1693
|
repoSpin.stop(
|
|
1694
1694
|
located.kind === "cloned" ? pc2.green(`\u2713 Cloned ${owner}/${repoName} to ${repoPath}`) : pc2.green(`\u2713 Found region repo at ${repoPath}`)
|
|
1695
1695
|
);
|
|
1696
|
-
const
|
|
1697
|
-
if (!
|
|
1698
|
-
p3.cancel(
|
|
1699
|
-
"1Password CLI not installed or not signed in. Run `op signin` and re-run bootstrap. (Or paste pgsodium key + Tunnel token by hand \u2014 manual path not in v0.1.)"
|
|
1700
|
-
);
|
|
1701
|
-
process.exit(1);
|
|
1702
|
-
}
|
|
1703
|
-
const keyTitle = `opuspopuli-${region}-pgsodium-root-key`;
|
|
1704
|
-
const tunnelTitle = `opuspopuli-${region}-tunnel-token`;
|
|
1705
|
-
const vaultArg = opts.vault ? { vault: opts.vault } : {};
|
|
1706
|
-
const opSpin = p3.spinner();
|
|
1707
|
-
opSpin.start("Reading pgsodium key + Tunnel token from 1Password\u2026");
|
|
1708
|
-
const pgsodiumKey = await readSecretFromOp({ title: keyTitle, ...vaultArg });
|
|
1709
|
-
const tunnelToken = await readSecretFromOp({ title: tunnelTitle, ...vaultArg });
|
|
1710
|
-
if (!pgsodiumKey || !tunnelToken) {
|
|
1711
|
-
opSpin.stop(pc2.red("\u2717 Required 1Password items missing."));
|
|
1712
|
-
const missing = [
|
|
1713
|
-
pgsodiumKey ? null : keyTitle,
|
|
1714
|
-
tunnelToken ? null : tunnelTitle
|
|
1715
|
-
].filter(Boolean);
|
|
1696
|
+
const keychain = await detectKeychain();
|
|
1697
|
+
if (!keychain.available) {
|
|
1716
1698
|
p3.cancel(
|
|
1717
|
-
|
|
1699
|
+
`${keychain.reason ?? "Keychain unavailable"}. Bootstrap requires the macOS Keychain.`
|
|
1718
1700
|
);
|
|
1719
1701
|
process.exit(1);
|
|
1720
1702
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1703
|
+
const pgsodiumKey = await loadSecret({
|
|
1704
|
+
region,
|
|
1705
|
+
account: "pgsodium-root-key",
|
|
1706
|
+
label: "pgsodium master key",
|
|
1707
|
+
validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters",
|
|
1708
|
+
...opts.localOnly ? { generate: generatePgsodiumRootKey } : {}
|
|
1709
|
+
});
|
|
1710
|
+
const tunnelToken = opts.localOnly ? void 0 : await loadSecret({
|
|
1711
|
+
region,
|
|
1712
|
+
account: "tunnel-token",
|
|
1713
|
+
label: "Cloudflare Tunnel token",
|
|
1714
|
+
placeholder: "JWT-style base64url string from `terraform output tunnel_token`",
|
|
1715
|
+
validate: (v) => TUNNEL_TOKEN_RE.test(v) ? void 0 : "tunnel token must be a base64-url JWT"
|
|
1716
|
+
});
|
|
1727
1717
|
if (!opts.skipLaunchAgent) {
|
|
1728
1718
|
const laSpin = p3.spinner();
|
|
1729
1719
|
laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
|
|
1730
|
-
const la = await setupLaunchAgent({
|
|
1720
|
+
const la = await setupLaunchAgent({
|
|
1721
|
+
pgsodiumKey,
|
|
1722
|
+
...tunnelToken !== void 0 ? { tunnelToken } : {}
|
|
1723
|
+
});
|
|
1731
1724
|
if (!la.ok) {
|
|
1732
1725
|
laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
|
|
1733
1726
|
p3.cancel(la.reason ?? "LaunchAgent setup failed.");
|
|
1734
1727
|
process.exit(1);
|
|
1735
1728
|
}
|
|
1736
|
-
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
|
+
);
|
|
1737
1734
|
}
|
|
1738
1735
|
const ghcrSpin = p3.spinner();
|
|
1739
1736
|
ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
|
|
@@ -1784,19 +1781,32 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1784
1781
|
}
|
|
1785
1782
|
}
|
|
1786
1783
|
if (opts.skipStack) {
|
|
1784
|
+
const profileFlag = opts.localOnly ? "" : "--profile public ";
|
|
1787
1785
|
p3.outro(
|
|
1788
1786
|
pc2.cyan(
|
|
1789
|
-
`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.`
|
|
1790
1788
|
)
|
|
1791
1789
|
);
|
|
1792
1790
|
return;
|
|
1793
1791
|
}
|
|
1794
|
-
const composeFiles = resolveComposeFiles(repoPath,
|
|
1792
|
+
const composeFiles = resolveComposeFiles(repoPath, composeFile);
|
|
1795
1793
|
const composeOpts = {
|
|
1796
1794
|
files: composeFiles,
|
|
1797
1795
|
cwd: repoPath,
|
|
1798
|
-
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
1796
|
+
...opts.envFile ? { envFile: opts.envFile } : {},
|
|
1797
|
+
profiles: opts.localOnly ? LOCAL_PROFILES : PUBLIC_PROFILES
|
|
1799
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
|
+
}
|
|
1800
1810
|
const pullSpin = p3.spinner();
|
|
1801
1811
|
pullSpin.start("Pulling images from ghcr.io\u2026");
|
|
1802
1812
|
const pull = await composePull(composeOpts);
|
|
@@ -1827,11 +1837,23 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1827
1837
|
switch (outcome.kind) {
|
|
1828
1838
|
case "healthy":
|
|
1829
1839
|
healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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
|
+
}
|
|
1835
1857
|
return;
|
|
1836
1858
|
case "unhealthy":
|
|
1837
1859
|
healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
|
|
@@ -1958,6 +1980,47 @@ async function promptTailscaleSignin() {
|
|
|
1958
1980
|
);
|
|
1959
1981
|
unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
|
|
1960
1982
|
}
|
|
1983
|
+
async function loadSecret(input) {
|
|
1984
|
+
const coords = { region: input.region, account: input.account };
|
|
1985
|
+
const spin = p3.spinner();
|
|
1986
|
+
spin.start(`Reading ${input.label} from Keychain\u2026`);
|
|
1987
|
+
const existing = await readSecret(coords);
|
|
1988
|
+
if (existing && input.validate(existing) === void 0) {
|
|
1989
|
+
spin.stop(pc2.green(`\u2713 ${input.label} read from Keychain.`));
|
|
1990
|
+
return existing;
|
|
1991
|
+
}
|
|
1992
|
+
spin.stop(
|
|
1993
|
+
existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed format validation \u2014 will replace.`) : pc2.dim(`\xB7 ${input.label} not in Keychain.`)
|
|
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
|
+
}
|
|
2007
|
+
const pasted = unwrap(
|
|
2008
|
+
await p3.password({
|
|
2009
|
+
message: `Paste the ${input.label} for region ${input.region}:`,
|
|
2010
|
+
validate: (v) => input.validate(v ?? "")
|
|
2011
|
+
})
|
|
2012
|
+
);
|
|
2013
|
+
const save = await saveSecret(coords, pasted);
|
|
2014
|
+
if (!save.written) {
|
|
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
|
+
);
|
|
2019
|
+
} else {
|
|
2020
|
+
p3.note(`${pc2.green("\u2713")} Stored ${input.label} in Keychain for re-runs.`, "Keychain");
|
|
2021
|
+
}
|
|
2022
|
+
return pasted;
|
|
2023
|
+
}
|
|
1961
2024
|
var REGION_RE = /^[a-z0-9-]{2,32}$/;
|
|
1962
2025
|
var RESET_PHASES = {
|
|
1963
2026
|
STOP_STACK: "Stop stack",
|
|
@@ -3665,7 +3728,7 @@ async function looksLikeRegionsRepo(dir) {
|
|
|
3665
3728
|
}
|
|
3666
3729
|
|
|
3667
3730
|
// src/cli.ts
|
|
3668
|
-
var VERSION = "0.
|
|
3731
|
+
var VERSION = "0.4.0";
|
|
3669
3732
|
var program = new Command();
|
|
3670
3733
|
program.name("create-op-node").description(
|
|
3671
3734
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|