create-op-node 0.0.1 → 0.2.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/dist/cli.js CHANGED
@@ -1,12 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from 'commander';
3
- import pc from 'picocolors';
4
- import * as p from '@clack/prompts';
3
+ import pc2 from 'picocolors';
4
+ import * as p3 from '@clack/prompts';
5
+ import { execa } from 'execa';
6
+ import { Octokit } from '@octokit/rest';
7
+ import { randomBytes } from 'crypto';
8
+ import { join, resolve, dirname } from 'path';
9
+ import { mkdir, writeFile, access, rm, chmod } from 'fs/promises';
10
+ import { homedir } from 'os';
11
+ import { readFileSync } from 'fs';
12
+ import * as tls from 'tls';
13
+ import { Validator } from '@cfworker/json-schema';
5
14
 
6
15
  // src/lib/cloudflare.ts
7
16
  var CF_API = "https://api.cloudflare.com/client/v4";
8
- async function get(token, path) {
9
- const res = await fetch(`${CF_API}${path}`, {
17
+ async function get(token, path, fetchImpl = fetch) {
18
+ const res = await fetchImpl(`${CF_API}${path}`, {
10
19
  headers: { Authorization: `Bearer ${token}` }
11
20
  });
12
21
  let body = null;
@@ -55,190 +64,3624 @@ async function probeCloudflareToken(input) {
55
64
  }
56
65
  return { ok: issues.length === 0, issues };
57
66
  }
67
+ async function tunnelStatus(input) {
68
+ const fetchImpl = input.fetchImpl ?? fetch;
69
+ try {
70
+ const res = await get(
71
+ input.token,
72
+ `/accounts/${input.accountId}/cfd_tunnel/${input.tunnelId}`,
73
+ fetchImpl
74
+ );
75
+ if (res.status !== 200) {
76
+ return {
77
+ ok: false,
78
+ reason: `tunnel lookup failed (HTTP ${res.status}): ${shortError(res.body)}`
79
+ };
80
+ }
81
+ const env = res.body;
82
+ const status = env.result?.status ?? "<no status>";
83
+ const connections = env.result?.connections?.length ?? 0;
84
+ return { ok: true, connections, status };
85
+ } catch (err) {
86
+ return { ok: false, reason: `tunnel lookup threw: ${err.message}` };
87
+ }
88
+ }
89
+
90
+ // src/lib/tfc.ts
91
+ var TFC_API = "https://app.terraform.io/api/v2";
92
+ var ORG_SLUG_RE = /^[A-Za-z0-9_-]{1,40}$/;
93
+ function isValidTfcOrgSlug(slug) {
94
+ return ORG_SLUG_RE.test(slug);
95
+ }
96
+ function authHeaders(token) {
97
+ return {
98
+ Authorization: `Bearer ${token}`,
99
+ "Content-Type": "application/vnd.api+json"
100
+ };
101
+ }
102
+ async function getJson(token, path) {
103
+ const res = await fetch(`${TFC_API}${path}`, { headers: authHeaders(token) });
104
+ let body = null;
105
+ try {
106
+ body = await res.json();
107
+ } catch {
108
+ }
109
+ return { status: res.status, body };
110
+ }
111
+ async function probeTfcToken(input) {
112
+ const issues = [];
113
+ if (!isValidTfcOrgSlug(input.organization)) {
114
+ issues.push(
115
+ `Organization "${input.organization}" isn't a valid TFC slug (letters, digits, hyphens, underscores; up to 40 chars).`
116
+ );
117
+ return { ok: false, issues };
118
+ }
119
+ const account = await getJson(input.token, "/account/details");
120
+ if (account.status !== 200) {
121
+ issues.push(
122
+ `TFC token invalid (HTTP ${account.status}). Regenerate at https://app.terraform.io/app/settings/tokens.`
123
+ );
124
+ return { ok: false, issues };
125
+ }
126
+ const userName = extractUserName(account.body);
127
+ const org = await getJson(
128
+ input.token,
129
+ `/organizations/${encodeURIComponent(input.organization)}`
130
+ );
131
+ if (org.status === 404) {
132
+ issues.push(
133
+ `TFC organization "${input.organization}" not found, or this token lacks access. Check the org slug at https://app.terraform.io.`
134
+ );
135
+ } else if (org.status === 401 || org.status === 403) {
136
+ issues.push(
137
+ `TFC token doesn't have permission to access org "${input.organization}".`
138
+ );
139
+ } else if (org.status !== 200) {
140
+ issues.push(`TFC org probe returned HTTP ${org.status}.`);
141
+ }
142
+ return {
143
+ ok: issues.length === 0,
144
+ issues,
145
+ ...userName ? { userName } : {}
146
+ };
147
+ }
148
+ function extractUserName(body) {
149
+ if (typeof body === "object" && body !== null && "data" in body && typeof body.data === "object") {
150
+ const data = body.data;
151
+ return data.attributes?.username;
152
+ }
153
+ return void 0;
154
+ }
155
+ async function findWorkspace(input) {
156
+ if (!isValidTfcOrgSlug(input.organization)) return null;
157
+ const tagFilter = encodeURIComponent(input.tags.join(","));
158
+ const res = await getJson(
159
+ input.token,
160
+ `/organizations/${encodeURIComponent(input.organization)}/workspaces?filter[tagged]=${tagFilter}&page[size]=100`
161
+ );
162
+ if (res.status !== 200) return null;
163
+ const items = extractWorkspaceList(res.body);
164
+ if (items.length === 0) return null;
165
+ const match = input.name ? items.find((w) => w.name === input.name) : items[0];
166
+ return match ?? null;
167
+ }
168
+ function extractWorkspaceList(body) {
169
+ if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
170
+ return [];
171
+ }
172
+ const data = body.data;
173
+ return data.map((w) => ({
174
+ id: w.id,
175
+ name: w.attributes?.name ?? "",
176
+ currentRunId: w.relationships?.["current-run"]?.data?.id ?? null
177
+ }));
178
+ }
179
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
180
+ "applied",
181
+ "errored",
182
+ "canceled",
183
+ "discarded",
184
+ "policy_soft_failed",
185
+ "force_canceled"
186
+ ]);
187
+ async function getRunStatus(auth, runId) {
188
+ const res = await getJson(auth.token, `/runs/${encodeURIComponent(runId)}`);
189
+ if (res.status !== 200) return null;
190
+ const body = res.body;
191
+ const id = body.data?.id;
192
+ const status = body.data?.attributes?.status;
193
+ if (!id || !status) return null;
194
+ return {
195
+ id,
196
+ status,
197
+ finished: TERMINAL_STATUSES.has(status),
198
+ succeeded: status === "applied"
199
+ };
200
+ }
201
+ async function fetchOutput(auth, workspaceId, outputName) {
202
+ const res = await getJson(
203
+ auth.token,
204
+ `/workspaces/${encodeURIComponent(workspaceId)}/current-state-version-outputs`
205
+ );
206
+ if (res.status !== 200) return null;
207
+ const body = res.body;
208
+ const match = body.data?.find((o) => o.attributes?.name === outputName);
209
+ if (!match) return null;
210
+ const value = match.attributes?.value;
211
+ return typeof value === "string" ? value : null;
212
+ }
213
+ async function safeExeca(cmd, args, options) {
214
+ try {
215
+ const result2 = await execa(cmd, args, { reject: false, ...options });
216
+ return {
217
+ exitCode: result2.exitCode,
218
+ stdout: result2.stdout,
219
+ stderr: result2.stderr
220
+ };
221
+ } catch (err) {
222
+ const e = err;
223
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ // src/lib/onepassword.ts
229
+ async function detectOp() {
230
+ const version = await safeExeca("op", ["--version"]);
231
+ if (version === null) {
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
+ };
250
+ }
251
+ async function saveSecretToOp(input) {
252
+ const vaultArg = input.vault ? ["--vault", input.vault] : [];
253
+ const existing = await safeExeca("op", ["item", "get", input.title, ...vaultArg, "--format=json"]);
254
+ if (existing === null) {
255
+ return {
256
+ written: false,
257
+ alreadyExisted: false,
258
+ reason: "`op` CLI not installed"
259
+ };
260
+ }
261
+ if (existing.exitCode === 0) {
262
+ if (!input.overwrite) {
263
+ return { written: false, alreadyExisted: true };
264
+ }
265
+ const edit = await safeExeca(
266
+ "op",
267
+ ["item", "edit", input.title, ...vaultArg, `notesPlain=${input.value}`]
268
+ );
269
+ if (edit === null || edit.exitCode !== 0) {
270
+ return {
271
+ written: false,
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}`
287
+ ]);
288
+ if (create === null || create.exitCode !== 0) {
289
+ return {
290
+ written: false,
291
+ alreadyExisted: false,
292
+ reason: `op item create failed: ${create?.stderr || create?.stdout || "`op` not installed"}`
293
+ };
294
+ }
295
+ return { written: true, alreadyExisted: false };
296
+ }
297
+ async function readSecretFromOp(input) {
298
+ const vaultArg = input.vault ? ["--vault", input.vault] : [];
299
+ const res = await safeExeca(
300
+ "op",
301
+ ["item", "get", input.title, ...vaultArg, "--fields", "notesPlain", "--reveal"]
302
+ );
303
+ if (res === null || res.exitCode !== 0) return null;
304
+ const value = res.stdout.trim();
305
+ return value.length > 0 ? value : null;
306
+ }
307
+ function unwrap(value) {
308
+ if (p3.isCancel(value)) {
309
+ p3.cancel("Cancelled.");
310
+ process.exit(0);
311
+ }
312
+ return value;
313
+ }
314
+ var _client = null;
315
+ var _clientToken = null;
316
+ function client(token) {
317
+ if (_client && _clientToken === token) return _client;
318
+ _client = new Octokit({ auth: token });
319
+ _clientToken = token;
320
+ return _client;
321
+ }
322
+ async function createRepoFromTemplate(input) {
323
+ const [templateOwner, templateRepo] = input.template.split("/");
324
+ if (!templateOwner || !templateRepo) {
325
+ throw new Error(`Bad template spec "${input.template}" \u2014 expected <owner>/<repo>`);
326
+ }
327
+ const res = await client(input.token).request(
328
+ "POST /repos/{template_owner}/{template_repo}/generate",
329
+ {
330
+ template_owner: templateOwner,
331
+ template_repo: templateRepo,
332
+ owner: input.owner,
333
+ name: input.name,
334
+ private: input.private ?? false,
335
+ include_all_branches: input.includeAllBranches ?? false,
336
+ ...input.description ? { description: input.description } : {}
337
+ }
338
+ );
339
+ return {
340
+ fullName: res.data.full_name,
341
+ htmlUrl: res.data.html_url,
342
+ defaultBranch: res.data.default_branch ?? "main"
343
+ };
344
+ }
345
+ async function setRepoSecret(input) {
346
+ const res = await safeExeca(
347
+ "gh",
348
+ ["secret", "set", input.name, "--repo", input.repo, "--body", "-"],
349
+ { input: input.value }
350
+ );
351
+ if (res === null) {
352
+ return {
353
+ written: false,
354
+ reason: "`gh` CLI not installed \u2014 install from https://cli.github.com or seed secrets manually"
355
+ };
356
+ }
357
+ if (res.exitCode !== 0) {
358
+ return {
359
+ written: false,
360
+ reason: `gh secret set failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
361
+ };
362
+ }
363
+ return { written: true };
364
+ }
365
+ async function commitFile(input) {
366
+ const [owner, repo] = input.repo.split("/");
367
+ if (!owner || !repo) {
368
+ throw new Error(`Bad repo "${input.repo}" \u2014 expected <owner>/<repo>`);
369
+ }
370
+ const octokit = client(input.token);
371
+ let existingSha;
372
+ try {
373
+ const existing = await octokit.repos.getContent({
374
+ owner,
375
+ repo,
376
+ path: input.path,
377
+ ref: input.branch
378
+ });
379
+ if (!Array.isArray(existing.data) && existing.data.type === "file") {
380
+ existingSha = existing.data.sha;
381
+ }
382
+ } catch (err) {
383
+ if (!is404(err)) throw err;
384
+ }
385
+ const res = await octokit.repos.createOrUpdateFileContents({
386
+ owner,
387
+ repo,
388
+ path: input.path,
389
+ branch: input.branch,
390
+ message: input.message,
391
+ content: Buffer.from(input.content, "utf8").toString("base64"),
392
+ ...existingSha ? { sha: existingSha } : {}
393
+ });
394
+ return {
395
+ commitSha: res.data.commit.sha ?? "",
396
+ contentSha: res.data.content?.sha ?? ""
397
+ };
398
+ }
399
+ function is404(err) {
400
+ return typeof err === "object" && err !== null && "status" in err && err.status === 404;
401
+ }
402
+ async function createBranch(input) {
403
+ const [owner, repo] = input.repo.split("/");
404
+ if (!owner || !repo) {
405
+ throw new Error(`Bad repo "${input.repo}" \u2014 expected <owner>/<repo>`);
406
+ }
407
+ const octokit = client(input.token);
408
+ const ref = await octokit.git.getRef({
409
+ owner,
410
+ repo,
411
+ ref: `heads/${input.fromBranch}`
412
+ });
413
+ await octokit.git.createRef({
414
+ owner,
415
+ repo,
416
+ ref: `refs/heads/${input.branch}`,
417
+ sha: ref.data.object.sha
418
+ });
419
+ }
420
+ async function openPullRequest(input) {
421
+ const [owner, repo] = input.repo.split("/");
422
+ if (!owner || !repo) {
423
+ throw new Error(`Bad repo "${input.repo}" \u2014 expected <owner>/<repo>`);
424
+ }
425
+ const octokit = client(input.token);
426
+ const res = await octokit.pulls.create({
427
+ owner,
428
+ repo,
429
+ head: input.head,
430
+ base: input.base,
431
+ title: input.title,
432
+ body: input.body
433
+ });
434
+ return { number: res.data.number, htmlUrl: res.data.html_url };
435
+ }
436
+ function generatePgsodiumRootKey() {
437
+ return randomBytes(32).toString("hex");
438
+ }
439
+ function renderProdTfvars(input) {
440
+ const project = input.project ?? "opuspopuli";
441
+ const api = input.apiSubdomain ?? "api";
442
+ const app = input.appSubdomain ?? "app";
443
+ const port = input.tunnelApiPort ?? 8080;
444
+ const r2 = input.r2LocationHint ?? "WNAM";
445
+ return `# =============================================================================
446
+ # Production environment \u2014 generated by create-op-node init
447
+ # =============================================================================
448
+ # Region-specific values for the OpusPopuli/opuspopuli-node template's
449
+ # infra/cloudflare/ Terraform. Edit by hand to tweak.
450
+
451
+ project = ${JSON.stringify(project)}
452
+
453
+ domain_name = ${JSON.stringify(input.domain)}
454
+
455
+ api_subdomain = ${JSON.stringify(api)}
456
+ app_subdomain = ${JSON.stringify(app)}
457
+
458
+ tunnel_api_port = ${port}
459
+
460
+ r2_location_hint = ${JSON.stringify(r2)}
461
+
462
+ enable_tunnel = true
463
+ enable_frontend = true
464
+ enable_r2 = true
465
+ `;
466
+ }
467
+
468
+ // src/lib/polling.ts
469
+ var DEFAULT_BUDGETS = {
470
+ discoveryMs: 60 * 1e3,
471
+ runMs: 10 * 60 * 1e3,
472
+ pollMs: 10 * 1e3
473
+ };
474
+ var realDeps = {
475
+ findWorkspace,
476
+ getRunStatus,
477
+ fetchOutput,
478
+ sleep: (ms) => new Promise((res) => setTimeout(res, ms)),
479
+ now: () => Date.now()
480
+ };
481
+ async function waitForApply(input, budgets = DEFAULT_BUDGETS, deps = realDeps) {
482
+ let runId = input.runId;
483
+ if (!runId) {
484
+ deps.onProgress?.("discovery");
485
+ const discoveryStart = deps.now();
486
+ while (deps.now() - discoveryStart < budgets.discoveryMs) {
487
+ await deps.sleep(budgets.pollMs);
488
+ const ws = await deps.findWorkspace({
489
+ token: input.token,
490
+ organization: input.organization,
491
+ tags: input.workspaceTags
492
+ });
493
+ if (ws?.currentRunId) {
494
+ runId = ws.currentRunId;
495
+ break;
496
+ }
497
+ }
498
+ if (!runId) return { kind: "no-run-started" };
499
+ }
500
+ deps.onProgress?.("run");
501
+ const runStart = deps.now();
502
+ while (deps.now() - runStart < budgets.runMs) {
503
+ const r = await deps.getRunStatus(
504
+ { token: input.token, organization: input.organization },
505
+ runId
506
+ );
507
+ if (r?.finished) {
508
+ if (!r.succeeded) return { kind: "run-failed", status: r.status };
509
+ deps.onProgress?.("fetching");
510
+ const value = await deps.fetchOutput(
511
+ { token: input.token, organization: input.organization },
512
+ input.workspaceId,
513
+ input.outputName
514
+ );
515
+ return value !== null ? { kind: "success", value } : { kind: "output-missing" };
516
+ }
517
+ await deps.sleep(budgets.pollMs);
518
+ }
519
+ return { kind: "timeout" };
520
+ }
58
521
 
59
522
  // src/commands/init.ts
523
+ async function ghTokenFromCli() {
524
+ const r = await safeExeca("gh", ["auth", "token"]);
525
+ if (r === null || r.exitCode !== 0) return null;
526
+ const token = r.stdout.trim();
527
+ return token.length > 0 ? token : null;
528
+ }
60
529
  var initCommand = new Command("init").description(
61
530
  "Stand up the node repo + Cloudflare infrastructure for a new region. Run this on your laptop."
531
+ ).addOption(new Option("--domain <domain>", "Your registered domain (e.g. example.org)")).addOption(new Option("--region <slug>", "Short label for your region (e.g. us-ca)")).addOption(new Option("--owner <owner>", "GitHub owner for the new node repo").default("OpusPopuli")).addOption(
532
+ new Option("--template <owner/repo>", "Template repo to clone from").default(
533
+ "OpusPopuli/opuspopuli-node"
534
+ )
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(new Option("--vault <vault>", "1Password vault for secrets").default("Private")).addOption(
536
+ new Option(
537
+ "--overwrite",
538
+ "Overwrite existing 1Password items (pgsodium key, Tunnel token) on a re-run"
539
+ ).default(false)
62
540
  ).addOption(
63
- new Option("--domain <domain>", "Your registered domain (e.g. example.org)")
64
- ).addOption(
65
- new Option("--region <slug>", "Short label for your region (e.g. us-ca)")
66
- ).addOption(
67
- new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")
68
- ).addOption(
69
- new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")
70
- ).addOption(
71
- new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")
72
- ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
73
- p.intro(pc.bgCyan(pc.black(" create-op-node ")));
74
- p.note(
541
+ new Option(
542
+ "--use-existing-repo",
543
+ "Continue using a previously-created node repo (don't fail on 'already exists')"
544
+ ).default(false)
545
+ ).addOption(new Option("--skip-wait", "Don't poll for Terraform apply; exit after PR open").default(false)).addOption(new Option("-y, --yes", "Skip the final confirmation").default(false)).action(async (opts) => {
546
+ p3.intro(pc2.bgCyan(pc2.black(" create-op-node init ")));
547
+ p3.note(
75
548
  [
76
549
  "This walks you from a sealed Mac Studio + a Cloudflare account to a",
77
550
  "live federation node serving traffic at api.<your-domain>.",
78
551
  "",
79
- pc.dim("Phase 1: laptop side \u2014 Cloudflare, GitHub, Terraform Cloud."),
80
- pc.dim("Phase 2: Studio side \u2014 run `create-op-node bootstrap` on the Mac.")
552
+ pc2.dim("Phase 1: laptop side (now) \u2014 Cloudflare, GitHub, Terraform Cloud."),
553
+ pc2.dim("Phase 2: Studio side \u2014 run `create-op-node bootstrap` on the Mac.")
81
554
  ].join("\n"),
82
555
  "Welcome"
83
556
  );
84
- const values = await p.group(
85
- {
86
- domain: () => opts.domain ? Promise.resolve(opts.domain) : p.text({
87
- message: "Domain registered in Cloudflare?",
88
- placeholder: "example.org",
89
- validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Looks like that domain is missing a TLD"
90
- }),
91
- region: () => opts.region ? Promise.resolve(opts.region) : p.text({
92
- message: "Short region label (used as a prefix in 1Password + R2)?",
93
- placeholder: "us-ca",
94
- validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
95
- })
96
- },
97
- {
98
- onCancel: () => {
99
- p.cancel("Cancelled.");
100
- process.exit(0);
101
- }
102
- }
557
+ const region = opts.region ? opts.region : unwrap(
558
+ await p3.text({
559
+ message: "Short region label (used as a prefix in 1Password + R2)?",
560
+ placeholder: "us-ca",
561
+ validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
562
+ })
103
563
  );
104
- const tokenSpinner = p.spinner();
105
- let cfToken = opts.cfToken;
106
- if (!cfToken) {
107
- const v = await p.password({
108
- message: "Paste your Cloudflare Account API token (input hidden)",
109
- validate: (v2) => v2 && v2.length >= 30 ? void 0 : "Token looks too short"
110
- });
111
- if (p.isCancel(v)) {
112
- p.cancel("Cancelled.");
113
- process.exit(0);
564
+ const domain = opts.domain ? opts.domain : unwrap(
565
+ await p3.text({
566
+ message: "Domain registered in Cloudflare?",
567
+ placeholder: "example.org",
568
+ validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
569
+ })
570
+ );
571
+ const owner = opts.owner ?? "OpusPopuli";
572
+ const newRepoName = `opuspopuli-node-${region}`;
573
+ const newRepoFull = `${owner}/${newRepoName}`;
574
+ const cfToken = await collectSecret(
575
+ opts.cfToken,
576
+ "Cloudflare Account API token (input hidden)",
577
+ "cloudflare"
578
+ );
579
+ const cfAccount = await collectId(opts.cfAccount, "Cloudflare account ID");
580
+ const cfZone = await collectId(opts.cfZone, `Cloudflare zone ID for ${domain}`);
581
+ const cfSpin = p3.spinner();
582
+ cfSpin.start("Verifying Cloudflare token + 5 scopes\u2026");
583
+ const cfProbe = await probeCloudflareToken({
584
+ token: cfToken,
585
+ accountId: cfAccount,
586
+ zoneId: cfZone
587
+ });
588
+ if (!cfProbe.ok) {
589
+ cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
590
+ for (const issue of cfProbe.issues) console.error(` - ${issue}`);
591
+ p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
592
+ process.exit(1);
593
+ }
594
+ cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
595
+ const tfToken = await collectSecret(
596
+ opts.tfToken,
597
+ "Terraform Cloud user/team API token",
598
+ "tfc"
599
+ );
600
+ const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
601
+ await p3.text({
602
+ message: "Terraform Cloud organization name?",
603
+ placeholder: "op-region-ca"
604
+ })
605
+ );
606
+ const tfSpin = p3.spinner();
607
+ tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
608
+ const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
609
+ if (!tfProbe.ok) {
610
+ tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
611
+ for (const issue of tfProbe.issues) console.error(` - ${issue}`);
612
+ p3.cancel("Fix the token / org, then re-run.");
613
+ process.exit(1);
614
+ }
615
+ tfSpin.stop(
616
+ pc2.green(
617
+ `\u2713 TFC token valid${tfProbe.userName ? ` (as ${tfProbe.userName})` : ""}, org "${tfOrg}" reachable.`
618
+ )
619
+ );
620
+ let ghToken = opts.ghToken;
621
+ if (!ghToken) {
622
+ const fromCli = await ghTokenFromCli();
623
+ if (fromCli) {
624
+ const useCli = unwrap(
625
+ await p3.confirm({
626
+ message: "Found a signed-in `gh` CLI \u2014 use its token?",
627
+ initialValue: true
628
+ })
629
+ );
630
+ if (useCli) ghToken = fromCli;
114
631
  }
115
- cfToken = v;
116
632
  }
117
- let cfAccount = opts.cfAccount;
118
- if (!cfAccount) {
119
- const v = await p.text({
120
- message: "Cloudflare account ID",
121
- placeholder: "0000000000000000000000000000000",
122
- validate: (v2) => /^[a-f0-9]{32}$/.test(v2 ?? "") ? void 0 : "Expected 32 hex characters"
123
- });
124
- if (p.isCancel(v)) {
125
- p.cancel("Cancelled.");
633
+ if (!ghToken) {
634
+ ghToken = await collectSecret(
635
+ void 0,
636
+ "GitHub Personal Access Token (repo scope)",
637
+ "github"
638
+ );
639
+ }
640
+ const op = await detectOp();
641
+ const opNote = op.installed ? op.signedIn ? `\u2713 1Password CLI signed in${op.email ? ` (${op.email})` : ""} \u2014 pgsodium key + Tunnel token will be saved automatically.` : `\u26A0 1Password CLI installed but not signed in. Run \`op signin\` in another shell, or you'll be prompted to paste secrets.` : `\u26A0 1Password CLI not installed. Secrets will be printed for you to paste into 1Password by hand.`;
642
+ p3.note(opNote, "1Password");
643
+ p3.note(
644
+ [
645
+ `Region label: ${pc2.cyan(region)}`,
646
+ `Domain: ${pc2.cyan(domain)}`,
647
+ `New node repo: ${pc2.cyan(newRepoFull)}`,
648
+ `Template: ${pc2.dim(opts.template ?? "OpusPopuli/opuspopuli-node")}`,
649
+ `TFC organization: ${pc2.cyan(tfOrg)}`,
650
+ ``,
651
+ `Will create the repo, seed 5 secrets, write prod.tfvars on a branch,`,
652
+ `open a PR, generate a fresh pgsodium key, and (after you merge) wait`,
653
+ `for terraform apply to finish and retrieve the Tunnel token.`
654
+ ].join("\n"),
655
+ "Plan"
656
+ );
657
+ if (!opts.yes) {
658
+ const go = unwrap(
659
+ await p3.confirm({ message: "Proceed?", initialValue: true })
660
+ );
661
+ if (!go) {
662
+ p3.cancel("Cancelled.");
126
663
  process.exit(0);
127
664
  }
128
- cfAccount = v;
129
665
  }
130
- let cfZone = opts.cfZone;
131
- if (!cfZone) {
132
- const v = await p.text({
133
- message: `Cloudflare zone ID for ${values.domain}`,
134
- placeholder: "0000000000000000000000000000000",
135
- validate: (v2) => /^[a-f0-9]{32}$/.test(v2 ?? "") ? void 0 : "Expected 32 hex characters"
666
+ const repoSpin = p3.spinner();
667
+ repoSpin.start(`Creating ${newRepoFull} from ${opts.template}\u2026`);
668
+ let created;
669
+ try {
670
+ created = await createRepoFromTemplate({
671
+ token: ghToken,
672
+ template: opts.template ?? "OpusPopuli/opuspopuli-node",
673
+ owner,
674
+ name: newRepoName,
675
+ description: `Opus Populi node deployment for ${region}`
136
676
  });
137
- if (p.isCancel(v)) {
138
- p.cancel("Cancelled.");
677
+ repoSpin.stop(pc2.green(`\u2713 Created ${created.fullName}`));
678
+ } catch (err) {
679
+ const status = typeof err === "object" && err !== null && "status" in err ? err.status : 0;
680
+ const message = err.message ?? "unknown error";
681
+ if (status !== 422) {
682
+ repoSpin.stop(pc2.red(`\u2717 Couldn't create repo: ${message}`));
683
+ p3.cancel("Fix the issue and re-run.");
684
+ process.exit(1);
685
+ }
686
+ repoSpin.stop(pc2.yellow(`\u26A0 ${newRepoFull} already exists.`));
687
+ const reuse = opts.useExistingRepo || unwrap(
688
+ await p3.confirm({
689
+ message: `Continue with the existing ${newRepoFull}? (Re-seeds secrets + re-commits prod.tfvars on a fresh branch.)`,
690
+ initialValue: true
691
+ })
692
+ );
693
+ if (!reuse) {
694
+ p3.cancel(
695
+ `Delete ${newRepoFull} on GitHub and re-run, or pass --use-existing-repo to skip this prompt.`
696
+ );
139
697
  process.exit(0);
140
698
  }
141
- cfZone = v;
699
+ created = {
700
+ fullName: newRepoFull,
701
+ htmlUrl: `https://github.com/${newRepoFull}`,
702
+ defaultBranch: "main"
703
+ };
142
704
  }
143
- tokenSpinner.start("Verifying token + scopes against the Cloudflare API\u2026");
144
- const probe = await probeCloudflareToken({
145
- token: cfToken,
146
- accountId: cfAccount,
147
- zoneId: cfZone
148
- });
149
- if (probe.ok) {
150
- tokenSpinner.stop(pc.green("\u2713 Token valid, all 5 scopes present."));
151
- } else {
152
- tokenSpinner.stop(pc.red("\u2717 Token check failed."));
153
- for (const issue of probe.issues) {
154
- console.error(` - ${issue}`);
705
+ const secrets = [
706
+ { name: "CLOUDFLARE_API_TOKEN", value: cfToken },
707
+ { name: "CLOUDFLARE_ACCOUNT_ID", value: cfAccount },
708
+ { name: "CLOUDFLARE_ZONE_ID", value: cfZone },
709
+ { name: "TF_API_TOKEN", value: tfToken },
710
+ { name: "TF_CLOUD_ORGANIZATION", value: tfOrg }
711
+ ];
712
+ const secSpin = p3.spinner();
713
+ secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
714
+ const seeded = [];
715
+ for (const s of secrets) {
716
+ const r = await setRepoSecret({ repo: newRepoFull, name: s.name, value: s.value });
717
+ if (!r.written) {
718
+ secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
719
+ const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
720
+ p3.cancel(
721
+ [
722
+ seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
723
+ `Still pending: ${remaining.join(", ")}.`,
724
+ "",
725
+ `Make sure \`gh\` is installed and signed in (\`gh auth login\`).`,
726
+ `Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
727
+ ].join("\n")
728
+ );
729
+ process.exit(1);
155
730
  }
156
- p.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
731
+ seeded.push(s.name);
732
+ }
733
+ secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
734
+ const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
735
+ const setupSpin = p3.spinner();
736
+ setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
737
+ try {
738
+ await createBranch({
739
+ token: ghToken,
740
+ repo: newRepoFull,
741
+ branch,
742
+ fromBranch: created.defaultBranch
743
+ });
744
+ const tfvars = renderProdTfvars({
745
+ project: opts.project ?? "opuspopuli",
746
+ domain
747
+ });
748
+ await commitFile({
749
+ token: ghToken,
750
+ repo: newRepoFull,
751
+ branch,
752
+ path: "infra/cloudflare/environments/prod.tfvars",
753
+ content: tfvars,
754
+ message: `init: prod.tfvars for ${region}`
755
+ });
756
+ } catch (err) {
757
+ setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
758
+ p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
759
+ process.exit(1);
760
+ }
761
+ setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
762
+ const prSpin = p3.spinner();
763
+ prSpin.start("Opening pull request\u2026");
764
+ let pr;
765
+ try {
766
+ pr = await openPullRequest({
767
+ token: ghToken,
768
+ repo: newRepoFull,
769
+ head: branch,
770
+ base: created.defaultBranch,
771
+ title: `init: bring up region ${region}`,
772
+ body: [
773
+ `Initial region deployment for **${region}** (${domain}).`,
774
+ "",
775
+ "Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
776
+ "`cloudflare-infra.yml` which runs `terraform apply` against the",
777
+ `\`${tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
778
+ "Tunnel, DNS records, R2 buckets, and Pages project.",
779
+ "",
780
+ `Generated by \`create-op-node init\`.`
781
+ ].join("\n")
782
+ });
783
+ } catch (err) {
784
+ prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
785
+ p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
157
786
  process.exit(1);
158
787
  }
159
- p.note(
788
+ prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
789
+ const keyTitle = `opuspopuli-${region}-pgsodium-root-key`;
790
+ const vaultArg = opts.vault ? { vault: opts.vault } : {};
791
+ if (op.installed && op.signedIn) {
792
+ const existing = opts.overwrite ? null : await readSecretFromOp({ title: keyTitle, ...vaultArg });
793
+ if (existing && /^[a-f0-9]{64}$/.test(existing)) {
794
+ p3.note(
795
+ `${pc2.green("\u2713")} Re-using existing pgsodium master key from 1Password (${pc2.cyan(keyTitle)}). Pass --overwrite to rotate.`,
796
+ "Secret"
797
+ );
798
+ } else {
799
+ const fresh = generatePgsodiumRootKey();
800
+ const r = await saveSecretToOp({
801
+ title: keyTitle,
802
+ value: fresh,
803
+ overwrite: opts.overwrite ?? false,
804
+ ...vaultArg
805
+ });
806
+ if (r.written) {
807
+ p3.note(
808
+ `${pc2.green("\u2713")} pgsodium master key${r.alreadyExisted ? " rotated" : " saved"} to 1Password as ${pc2.cyan(keyTitle)}.`,
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.`,
814
+ "Secret"
815
+ );
816
+ } else {
817
+ await printKeyForManualSave(fresh, keyTitle, r.reason);
818
+ }
819
+ }
820
+ } else {
821
+ const fresh = generatePgsodiumRootKey();
822
+ await printKeyForManualSave(fresh, keyTitle);
823
+ }
824
+ if (opts.skipWait) {
825
+ p3.outro(
826
+ pc2.cyan(
827
+ `Review + merge ${pr.htmlUrl} when ready. Re-run with no --skip-wait or run \`npx create-op-node verify\` afterwards.`
828
+ )
829
+ );
830
+ return;
831
+ }
832
+ p3.note(
160
833
  [
161
- pc.yellow("Stopping here in v0.0.1. Coming next:"),
834
+ `Open the PR: ${pc2.cyan(pr.htmlUrl)}`,
162
835
  "",
163
- " \u2022 Validate Terraform Cloud token",
164
- " \u2022 `gh repo create --template OpusPopuli/opuspopuli-node`",
165
- " \u2022 Seed 5 GitHub Secrets via Octokit",
166
- " \u2022 Write environments/prod.tfvars from your answers",
167
- " \u2022 Generate pgsodium master key + stash in 1Password (via `op` if available)",
168
- " \u2022 Open the first PR; wait for `terraform apply` to complete",
169
- " \u2022 Retrieve Tunnel token from TF Cloud outputs",
170
- " \u2022 Print next steps for `create-op-node bootstrap` on the Mac Studio"
836
+ "Review the `terraform plan` comment, then merge to main.",
837
+ "The workflow then runs `terraform apply` against Terraform Cloud."
171
838
  ].join("\n"),
172
- "Roadmap"
839
+ "Next step: review + merge"
173
840
  );
174
- p.outro(pc.cyan(`Ready for the next session. Your region: opuspopuli-node-${values.region}`));
175
- });
176
- var bootstrapCommand = new Command("bootstrap").description(
177
- "Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
178
- ).action(async () => {
179
- p.intro(pc.bgCyan(pc.black(" create-op-node bootstrap ")));
180
- p.note(
181
- [
182
- pc.yellow("Stub \u2014 coming in v0.0.3."),
183
- "",
184
- "Will run:",
185
- " \u2022 macOS sanity checks (auto-restart, FileVault off, disk-sleep off)",
186
- " \u2022 Homebrew install (if missing) + brew install git gh pnpm jq cloudflared rclone ollama",
187
- " \u2022 Docker Desktop install + GUI config prompts",
188
- " \u2022 `gh auth login` (browser flow)",
189
- " \u2022 `tailscale up` (browser flow)",
190
- " \u2022 Clone the node repo created by `init`",
191
- " \u2022 Materialize pgsodium key (from 1Password via `op`, or paste)",
192
- " \u2022 Write LaunchAgent plist + load it",
193
- " \u2022 Login to ghcr.io",
194
- " \u2022 `ollama pull qwen3.5:9b` (+ embeddings)",
195
- " \u2022 `docker compose -f docker-compose-prod.yml pull && up -d`",
196
- " \u2022 Health-check loop until all 10 containers report (healthy)"
197
- ].join("\n"),
198
- "Roadmap"
841
+ const merged = unwrap(
842
+ await p3.confirm({ message: "PR merged?", initialValue: false })
843
+ );
844
+ if (!merged) {
845
+ p3.outro(
846
+ pc2.cyan(`No worries \u2014 re-run \`create-op-node init --skip-wait\` later to skip this step, or finish manually.`)
847
+ );
848
+ return;
849
+ }
850
+ const ws = await findWorkspace({
851
+ token: tfToken,
852
+ organization: tfOrg,
853
+ tags: ["opuspopuli", "cloudflare"]
854
+ });
855
+ if (!ws) {
856
+ p3.cancel(
857
+ `Couldn't find a TFC workspace tagged opuspopuli + cloudflare in ${tfOrg}. Check the workflow's run log; the workspace is created on first apply.`
858
+ );
859
+ process.exit(1);
860
+ }
861
+ const tunnelToken = await waitForApplyAndFetchTunnelToken({
862
+ token: tfToken,
863
+ organization: tfOrg,
864
+ workspaceId: ws.id,
865
+ runId: ws.currentRunId
866
+ });
867
+ if (!tunnelToken) {
868
+ p3.cancel(
869
+ `Terraform apply didn't produce a tunnel_token output. Check the run on TFC; if it succeeded, the output may be named differently \u2014 patch and re-run, or fetch via 'terraform output -raw tunnel_token'.`
870
+ );
871
+ process.exit(1);
872
+ }
873
+ const tunnelTitle = `opuspopuli-${region}-tunnel-token`;
874
+ if (op.installed && op.signedIn) {
875
+ const r = await saveSecretToOp({
876
+ title: tunnelTitle,
877
+ value: tunnelToken,
878
+ ...opts.vault ? { vault: opts.vault } : {},
879
+ overwrite: true
880
+ });
881
+ if (r.written) {
882
+ p3.note(
883
+ `${pc2.green("\u2713")} Tunnel token saved to 1Password as ${pc2.cyan(tunnelTitle)}.`,
884
+ "Secret"
885
+ );
886
+ } else {
887
+ await printKeyForManualSave(tunnelToken, tunnelTitle, r.reason);
888
+ }
889
+ } else {
890
+ await printKeyForManualSave(tunnelToken, tunnelTitle);
891
+ }
892
+ p3.outro(
893
+ pc2.cyan(
894
+ `Region ${region} is provisioned. Next: \`npx create-op-node bootstrap\` on the Mac Studio.`
895
+ )
199
896
  );
200
- p.outro(pc.dim("See you next session."));
201
897
  });
202
- var verifyCommand = new Command("verify").description(
203
- "Off-LAN health probe of a live node \u2014 TLS, Apollo Federation reachability, GraphQL smoke. Run from anywhere with internet access."
204
- ).addOption(
205
- new Option("--domain <domain>", "Domain to probe (e.g. example.org \u2192 checks api.example.org)")
206
- ).action(async (opts) => {
207
- p.intro(pc.bgCyan(pc.black(" create-op-node verify ")));
208
- p.note(
898
+ var MIN_TOKEN_LENGTH = {
899
+ cloudflare: 40,
900
+ tfc: 40,
901
+ github: 40,
902
+ generic: 20
903
+ };
904
+ async function collectSecret(preset, message, kind = "generic") {
905
+ if (preset) return preset;
906
+ const min = MIN_TOKEN_LENGTH[kind];
907
+ const v = unwrap(
908
+ await p3.password({
909
+ message,
910
+ validate: (v2) => v2 && v2.length >= min ? void 0 : `That looks too short \u2014 expected at least ${min} characters for a ${kind} token`
911
+ })
912
+ );
913
+ return v;
914
+ }
915
+ async function collectId(preset, message) {
916
+ if (preset) return preset;
917
+ const v = unwrap(
918
+ await p3.text({
919
+ message,
920
+ placeholder: "0000000000000000000000000000000",
921
+ validate: (v2) => /^[a-f0-9]{32}$/.test(v2 ?? "") ? void 0 : "Expected 32 hex characters"
922
+ })
923
+ );
924
+ return v;
925
+ }
926
+ async function printKeyForManualSave(value, title, reason) {
927
+ p3.note(
209
928
  [
210
- pc.yellow("Stub \u2014 coming in v0.0.4."),
929
+ reason ? `${pc2.red("\u2717")} ${reason}` : "",
930
+ `Save this value to 1Password as ${pc2.cyan(title)}:`,
211
931
  "",
212
- opts.domain ? `Will probe api.${opts.domain}.` : "Will prompt for domain.",
932
+ pc2.yellow(value),
213
933
  "",
214
- "Will check:",
215
- " \u2022 TLS handshake to api.<domain>",
216
- " \u2022 GET /health returns 200",
217
- " \u2022 POST /api with a public introspection query returns valid GraphQL",
218
- " \u2022 cloudflared registered tunnel connections",
219
- " \u2022 Cosign signatures on the running images"
220
- ].join("\n"),
221
- "Roadmap"
934
+ pc2.dim("It will not be shown again.")
935
+ ].filter((line) => line.length > 0).join("\n"),
936
+ "Manual save"
222
937
  );
223
- p.outro(pc.dim("See you next session."));
224
- });
938
+ unwrap(
939
+ await p3.confirm({
940
+ message: "Value stashed in 1Password?",
941
+ initialValue: true
942
+ })
943
+ );
944
+ }
945
+ function isoStampUtc(d) {
946
+ const pad = (n, w = 2) => n.toString().padStart(w, "0");
947
+ return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
948
+ }
949
+ async function waitForApplyAndFetchTunnelToken(input) {
950
+ const spin = p3.spinner();
951
+ spin.start("Waiting for terraform apply to finish (polling every 10s)\u2026");
952
+ const outcome = await waitForApply({
953
+ token: input.token,
954
+ organization: input.organization,
955
+ workspaceId: input.workspaceId,
956
+ runId: input.runId,
957
+ workspaceTags: ["opuspopuli", "cloudflare"],
958
+ outputName: "tunnel_token"
959
+ });
960
+ switch (outcome.kind) {
961
+ case "success":
962
+ spin.stop(pc2.green("\u2713 Tunnel token retrieved."));
963
+ return outcome.value;
964
+ case "output-missing":
965
+ spin.stop(pc2.red("\u2717 Apply succeeded but tunnel_token output is missing."));
966
+ return null;
967
+ case "no-run-started":
968
+ spin.stop(pc2.red("\u2717 No run started in the workspace yet."));
969
+ return null;
970
+ case "run-failed":
971
+ spin.stop(pc2.red(`\u2717 Run finished with status "${outcome.status}".`));
972
+ return null;
973
+ case "timeout":
974
+ spin.stop(pc2.red("\u2717 Timed out after 10 minutes."));
975
+ return null;
976
+ }
977
+ }
978
+
979
+ // src/lib/constants.ts
980
+ var PGSODIUM_KEY_RE = /^[a-f0-9]{64}$/;
981
+ var TUNNEL_TOKEN_RE = /^[A-Za-z0-9_\-.=]+$/;
982
+ var SAFE_PATH_RE = /^[A-Za-z0-9_\-./ ]+$/;
983
+ var VERIFY_NETWORK_TIMEOUT_MS = 1e4;
984
+ var BODY_PREVIEW_MAX = 200;
985
+
986
+ // src/lib/homebrew.ts
987
+ var STUDIO_PACKAGES = [
988
+ { name: "git", kind: "formula" },
989
+ { name: "gh", kind: "formula" },
990
+ { name: "pnpm", kind: "formula" },
991
+ { name: "jq", kind: "formula" },
992
+ { name: "cloudflared", kind: "formula" },
993
+ { name: "rclone", kind: "formula" },
994
+ { name: "ollama", kind: "formula" },
995
+ { name: "docker", kind: "cask" },
996
+ { name: "tailscale", kind: "cask" }
997
+ ];
998
+ async function detectBrew() {
999
+ const res = await safeExeca("brew", ["--version"]);
1000
+ if (res === null || res.exitCode !== 0) {
1001
+ return { installed: false };
1002
+ }
1003
+ const version = res.stdout.split("\n").find((l) => l.startsWith("Homebrew "));
1004
+ return { installed: true, ...version ? { version } : {} };
1005
+ }
1006
+ var HOMEBREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
1007
+ async function isPackageInstalled(pkg) {
1008
+ const flag = pkg.kind === "cask" ? "--cask" : "--formula";
1009
+ const res = await safeExeca("brew", ["list", flag, pkg.name]);
1010
+ return res !== null && res.exitCode === 0;
1011
+ }
1012
+ async function installPackage(pkg) {
1013
+ const args = pkg.kind === "cask" ? ["install", "--cask", pkg.name] : ["install", pkg.name];
1014
+ const res = await safeExeca("brew", args);
1015
+ if (res === null) {
1016
+ return { ok: false, reason: "`brew` not on PATH" };
1017
+ }
1018
+ if (res.exitCode !== 0) {
1019
+ return {
1020
+ ok: false,
1021
+ reason: `brew install ${pkg.name} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
1022
+ };
1023
+ }
1024
+ return { ok: true };
1025
+ }
1026
+ async function installPackages(packages, onEach) {
1027
+ const report = {
1028
+ installed: [],
1029
+ alreadyPresent: [],
1030
+ failed: []
1031
+ };
1032
+ for (const pkg of packages) {
1033
+ onEach?.(pkg, "checking");
1034
+ if (await isPackageInstalled(pkg)) {
1035
+ report.alreadyPresent.push(pkg);
1036
+ onEach?.(pkg, "present");
1037
+ continue;
1038
+ }
1039
+ onEach?.(pkg, "installing");
1040
+ const r = await installPackage(pkg);
1041
+ if (r.ok) {
1042
+ report.installed.push(pkg);
1043
+ onEach?.(pkg, "installed");
1044
+ } else {
1045
+ report.failed.push({ pkg, reason: r.reason ?? "unknown" });
1046
+ onEach?.(pkg, "failed");
1047
+ }
1048
+ }
1049
+ return report;
1050
+ }
1051
+
1052
+ // src/lib/macos.ts
1053
+ async function inspectSystem() {
1054
+ const [hostnameOut, osVersionOut, pmsetOut, fdesetupOut, unameOut] = await Promise.all([
1055
+ safeExeca("hostname", []),
1056
+ safeExeca("sw_vers", ["-productVersion"]),
1057
+ safeExeca("pmset", ["-g"]),
1058
+ safeExeca("fdesetup", ["status"]),
1059
+ safeExeca("uname", ["-m"])
1060
+ ]);
1061
+ return {
1062
+ hostname: hostnameOut?.stdout.trim() ?? "unknown",
1063
+ osVersion: osVersionOut && osVersionOut.exitCode === 0 ? osVersionOut.stdout.trim() : null,
1064
+ autoRestartOnPowerFailure: parsePmsetBool(pmsetOut?.stdout ?? "", "autorestart", 1),
1065
+ diskSleepDisabled: parsePmsetBool(pmsetOut?.stdout ?? "", "disksleep", 0),
1066
+ fileVaultEnabled: parseFileVault(fdesetupOut?.stdout ?? ""),
1067
+ isAppleSilicon: (unameOut?.stdout.trim() ?? "") === "arm64"
1068
+ };
1069
+ }
1070
+ function parsePmsetBool(output, key, expected) {
1071
+ for (const line of output.split("\n")) {
1072
+ const match = line.match(/^\s*([a-z]+)\s+(-?\d+)\s*$/);
1073
+ if (match && match[1] === key) {
1074
+ return Number(match[2]) === expected;
1075
+ }
1076
+ }
1077
+ return false;
1078
+ }
1079
+ function parseFileVault(output) {
1080
+ const trimmed = output.trim();
1081
+ if (/^FileVault is On\.?/i.test(trimmed)) return true;
1082
+ if (/^FileVault is Off\.?/i.test(trimmed)) return false;
1083
+ if (/encryption in progress/i.test(trimmed)) return true;
1084
+ return "unknown";
1085
+ }
1086
+ async function enableAutoRestartOnPowerFailure() {
1087
+ return runSudoPmset(["autorestart", "1"]);
1088
+ }
1089
+ async function disableDiskSleep() {
1090
+ return runSudoPmset(["disksleep", "0"]);
1091
+ }
1092
+ async function runSudoPmset(args) {
1093
+ const res = await safeExeca("sudo", ["pmset", "-a", ...args]);
1094
+ if (res === null) {
1095
+ return { ok: false, reason: "`sudo` not on PATH" };
1096
+ }
1097
+ if (res.exitCode !== 0) {
1098
+ return {
1099
+ ok: false,
1100
+ reason: `pmset ${args.join(" ")} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
1101
+ };
1102
+ }
1103
+ return { ok: true };
1104
+ }
1105
+ var LAUNCH_AGENT_LABEL = "org.opuspopuli.envloader";
1106
+ function defaultPaths(home = homedir()) {
1107
+ return {
1108
+ keyFile: `${home}/.config/opuspopuli/pgsodium_root_key`,
1109
+ plistFile: `${home}/Library/LaunchAgents/${LAUNCH_AGENT_LABEL}.plist`
1110
+ };
1111
+ }
1112
+ async function writePgsodiumKeyFile(key, keyFile) {
1113
+ if (!PGSODIUM_KEY_RE.test(key)) {
1114
+ return {
1115
+ ok: false,
1116
+ reason: `pgsodium key must be 64 lowercase hex characters (got ${key.length} chars)`
1117
+ };
1118
+ }
1119
+ try {
1120
+ const dir = dirname(keyFile);
1121
+ await mkdir(dir, { recursive: true, mode: 448 });
1122
+ await writeFile(keyFile, key, { mode: 256 });
1123
+ await chmod(dir, 448);
1124
+ await chmod(keyFile, 256);
1125
+ return { ok: true };
1126
+ } catch (err) {
1127
+ return { ok: false, reason: `writing ${keyFile} failed: ${err.message}` };
1128
+ }
1129
+ }
1130
+ function renderLaunchAgentPlist(input) {
1131
+ if (!TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1132
+ throw new Error("Tunnel token contains characters outside the expected base64-url set");
1133
+ }
1134
+ if (!SAFE_PATH_RE.test(input.keyFilePath)) {
1135
+ throw new Error(
1136
+ `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
1137
+ );
1138
+ }
1139
+ const command = `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"; launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`;
1140
+ return [
1141
+ '<?xml version="1.0" encoding="UTF-8"?>',
1142
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1143
+ '<plist version="1.0">',
1144
+ "<dict>",
1145
+ ` <key>Label</key><string>${LAUNCH_AGENT_LABEL}</string>`,
1146
+ " <key>ProgramArguments</key>",
1147
+ " <array>",
1148
+ " <string>/bin/sh</string>",
1149
+ " <string>-c</string>",
1150
+ ` <string>${command}</string>`,
1151
+ " </array>",
1152
+ " <key>RunAtLoad</key><true/>",
1153
+ "</dict>",
1154
+ "</plist>",
1155
+ ""
1156
+ ].join("\n");
1157
+ }
1158
+ async function writeLaunchAgentPlist(plistFile, content) {
1159
+ try {
1160
+ await mkdir(dirname(plistFile), { recursive: true });
1161
+ await writeFile(plistFile, content, { mode: 384 });
1162
+ await chmod(plistFile, 384);
1163
+ return { ok: true };
1164
+ } catch (err) {
1165
+ return { ok: false, reason: `writing ${plistFile} failed: ${err.message}` };
1166
+ }
1167
+ }
1168
+ async function loadLaunchAgent(plistFile) {
1169
+ await safeExeca("launchctl", ["unload", plistFile]);
1170
+ const load = await safeExeca("launchctl", ["load", plistFile]);
1171
+ if (load === null) {
1172
+ return { ok: false, reason: "`launchctl` not on PATH" };
1173
+ }
1174
+ if (load.exitCode !== 0) {
1175
+ return {
1176
+ ok: false,
1177
+ reason: `launchctl load failed (${load.exitCode ?? "signal"}): ${load.stderr || load.stdout}`
1178
+ };
1179
+ }
1180
+ return { ok: true };
1181
+ }
1182
+ async function setupLaunchAgent(input) {
1183
+ const paths = input.paths ?? defaultPaths();
1184
+ const keyResult = await writePgsodiumKeyFile(input.pgsodiumKey, paths.keyFile);
1185
+ if (!keyResult.ok) {
1186
+ return { ok: false, paths, step: "key-file", ...keyResult.reason ? { reason: keyResult.reason } : {} };
1187
+ }
1188
+ let plistContent;
1189
+ try {
1190
+ plistContent = renderLaunchAgentPlist({
1191
+ keyFilePath: paths.keyFile,
1192
+ tunnelToken: input.tunnelToken
1193
+ });
1194
+ } catch (err) {
1195
+ return { ok: false, paths, step: "plist", reason: err.message };
1196
+ }
1197
+ const plistResult = await writeLaunchAgentPlist(paths.plistFile, plistContent);
1198
+ if (!plistResult.ok) {
1199
+ return { ok: false, paths, step: "plist", ...plistResult.reason ? { reason: plistResult.reason } : {} };
1200
+ }
1201
+ const loadResult = await loadLaunchAgent(paths.plistFile);
1202
+ if (!loadResult.ok) {
1203
+ return { ok: false, paths, step: "load", ...loadResult.reason ? { reason: loadResult.reason } : {} };
1204
+ }
1205
+ return { ok: true, paths };
1206
+ }
1207
+ async function teardownLaunchAgent(paths, opts = {}) {
1208
+ const steps = [];
1209
+ const unload = await safeExeca("launchctl", ["unload", paths.plistFile]);
1210
+ steps.push({
1211
+ step: "unload",
1212
+ ok: true,
1213
+ ...unload === null ? { reason: "`launchctl` not on PATH" } : {}
1214
+ });
1215
+ try {
1216
+ await rm(paths.plistFile, { force: true });
1217
+ steps.push({ step: "rm-plist", ok: true });
1218
+ } catch (err) {
1219
+ steps.push({ step: "rm-plist", ok: false, reason: err.message });
1220
+ }
1221
+ if (!opts.keepKeyFile) {
1222
+ try {
1223
+ await rm(paths.keyFile, { force: true });
1224
+ steps.push({ step: "rm-key-file", ok: true });
1225
+ } catch (err) {
1226
+ steps.push({ step: "rm-key-file", ok: false, reason: err.message });
1227
+ }
1228
+ }
1229
+ return { ok: steps.every((s) => s.ok), steps };
1230
+ }
1231
+
1232
+ // src/lib/docker.ts
1233
+ var GHCR_REGISTRY = "ghcr.io";
1234
+ async function loginToGhcr() {
1235
+ const tokenRes = await safeExeca("gh", ["auth", "token"]);
1236
+ if (tokenRes === null) return { ok: false, reason: "`gh` not installed" };
1237
+ if (tokenRes.exitCode !== 0) {
1238
+ return {
1239
+ ok: false,
1240
+ reason: `gh auth token failed (${tokenRes.exitCode ?? "signal"}): ${tokenRes.stderr || tokenRes.stdout}`
1241
+ };
1242
+ }
1243
+ const token = tokenRes.stdout.trim();
1244
+ if (token.length === 0) {
1245
+ return { ok: false, reason: "gh auth token returned empty \u2014 run `gh auth login` first" };
1246
+ }
1247
+ const userRes = await safeExeca("gh", ["api", "user", "--jq", ".login"]);
1248
+ if (userRes === null || userRes.exitCode !== 0) {
1249
+ return {
1250
+ ok: false,
1251
+ reason: `couldn't resolve GitHub user via gh api: ${userRes?.stderr || userRes?.stdout || "gh missing"}`
1252
+ };
1253
+ }
1254
+ const user = userRes.stdout.trim();
1255
+ if (user.length === 0) {
1256
+ return { ok: false, reason: "gh api user returned empty \u2014 gh not signed in" };
1257
+ }
1258
+ const loginRes = await safeExeca(
1259
+ "docker",
1260
+ ["login", GHCR_REGISTRY, "-u", user, "--password-stdin"],
1261
+ { input: token }
1262
+ );
1263
+ if (loginRes === null) return { ok: false, reason: "`docker` not installed" };
1264
+ if (loginRes.exitCode !== 0) {
1265
+ return {
1266
+ ok: false,
1267
+ reason: `docker login ${GHCR_REGISTRY} failed (${loginRes.exitCode ?? "signal"}): ${loginRes.stderr || loginRes.stdout}`
1268
+ };
1269
+ }
1270
+ return { ok: true };
1271
+ }
1272
+ function composeArgs(opts, sub) {
1273
+ const files = opts.files.flatMap((f) => ["-f", f]);
1274
+ const env = opts.envFile ? ["--env-file", opts.envFile] : [];
1275
+ return ["compose", ...files, ...env, ...sub];
1276
+ }
1277
+ async function composePull(opts) {
1278
+ const res = await safeExeca("docker", composeArgs(opts, ["pull"]));
1279
+ return result(res, "compose pull");
1280
+ }
1281
+ async function composeUp(opts) {
1282
+ const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
1283
+ return result(res, "compose up");
1284
+ }
1285
+ async function composeDown(opts) {
1286
+ const flags = ["down"];
1287
+ if (opts.wipeVolumes) flags.push("-v");
1288
+ if (opts.removeOrphans) flags.push("--remove-orphans");
1289
+ const res = await safeExeca("docker", composeArgs(opts, flags));
1290
+ return result(res, opts.wipeVolumes ? "compose down -v" : "compose down");
1291
+ }
1292
+ async function dockerLogout(registry = GHCR_REGISTRY) {
1293
+ const res = await safeExeca("docker", ["logout", registry]);
1294
+ return result(res, `docker logout ${registry}`);
1295
+ }
1296
+ function result(res, label) {
1297
+ if (res === null) return { ok: false, reason: "`docker` not installed" };
1298
+ if (res.exitCode !== 0) {
1299
+ return {
1300
+ ok: false,
1301
+ reason: `${label} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
1302
+ };
1303
+ }
1304
+ return { ok: true };
1305
+ }
1306
+ function parseComposePs(stdout) {
1307
+ const trimmed = stdout.trim();
1308
+ if (trimmed.length === 0) return [];
1309
+ const lines = trimmed.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
1310
+ const out = [];
1311
+ for (const line of lines) {
1312
+ try {
1313
+ const parsed = JSON.parse(line);
1314
+ if (Array.isArray(parsed)) {
1315
+ for (const item of parsed) out.push(normalize(item));
1316
+ } else {
1317
+ out.push(normalize(parsed));
1318
+ }
1319
+ } catch {
1320
+ }
1321
+ }
1322
+ return out;
1323
+ }
1324
+ function normalize(raw) {
1325
+ const r = raw ?? {};
1326
+ const name = r["Name"] ?? r["Service"] ?? "<unknown>";
1327
+ const state = (r["State"] ?? "unknown").toLowerCase();
1328
+ const healthRaw = (r["Health"] ?? "").toLowerCase();
1329
+ const health = healthRaw === "healthy" || healthRaw === "unhealthy" || healthRaw === "starting" ? healthRaw : "none";
1330
+ const exitCodeRaw = r["ExitCode"];
1331
+ const exitCode = typeof exitCodeRaw === "number" ? exitCodeRaw : null;
1332
+ return { name, state, health, exitCode };
1333
+ }
1334
+ async function composePs(opts) {
1335
+ const res = await safeExeca("docker", composeArgs(opts, ["ps", "--format", "json"]));
1336
+ if (res === null || res.exitCode !== 0) return null;
1337
+ return parseComposePs(res.stdout);
1338
+ }
1339
+ var realWaitDeps = {
1340
+ ps: composePs,
1341
+ sleep: (ms) => new Promise((res) => setTimeout(res, ms)),
1342
+ now: () => Date.now()
1343
+ };
1344
+ async function waitForHealthy(composeOpts, options = {}, deps = realWaitDeps) {
1345
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
1346
+ const basePollMs = options.pollMs ?? 5 * 1e3;
1347
+ const start = deps.now();
1348
+ let last = [];
1349
+ let pollCount = 0;
1350
+ while (deps.now() - start < timeoutMs) {
1351
+ pollCount++;
1352
+ const ps = await deps.ps(composeOpts);
1353
+ if (ps !== null) {
1354
+ last = ps;
1355
+ options.onPoll?.(ps);
1356
+ if (ps.length > 0) {
1357
+ const verdict = assessHealth(ps, options.requireHealthy);
1358
+ if (verdict.kind === "healthy") return { kind: "healthy", snapshots: ps };
1359
+ if (verdict.kind === "unhealthy") {
1360
+ return {
1361
+ kind: "unhealthy",
1362
+ snapshots: ps,
1363
+ problem: verdict.problem ?? "unhealthy"
1364
+ };
1365
+ }
1366
+ }
1367
+ }
1368
+ const interval = pollCount < 3 ? basePollMs : basePollMs * 2;
1369
+ await deps.sleep(interval);
1370
+ }
1371
+ return { kind: "timeout", snapshots: last };
1372
+ }
1373
+ function assessHealth(snapshots, requireHealthy) {
1374
+ for (const s of snapshots) {
1375
+ if (s.health === "unhealthy") {
1376
+ return { kind: "unhealthy", problem: `${s.name} reports unhealthy` };
1377
+ }
1378
+ if (s.state === "exited" && (s.exitCode ?? 0) !== 0) {
1379
+ return { kind: "unhealthy", problem: `${s.name} exited with code ${s.exitCode}` };
1380
+ }
1381
+ }
1382
+ const required = requireHealthy ?? [];
1383
+ if (required.length > 0) {
1384
+ const byName = new Map(snapshots.map((s) => [s.name, s]));
1385
+ for (const name of required) {
1386
+ const s = byName.get(name);
1387
+ if (!s) return { kind: "pending" };
1388
+ if (s.state === "exited" && s.exitCode === 0) continue;
1389
+ if (s.state !== "running") return { kind: "pending" };
1390
+ if (s.health === "starting") return { kind: "pending" };
1391
+ if (s.health === "unhealthy") {
1392
+ return { kind: "unhealthy", problem: `${s.name} reports unhealthy` };
1393
+ }
1394
+ }
1395
+ return { kind: "healthy" };
1396
+ }
1397
+ for (const s of snapshots) {
1398
+ if (s.state === "exited" && s.exitCode === 0) continue;
1399
+ if (s.state !== "running") return { kind: "pending" };
1400
+ if (s.health === "starting") return { kind: "pending" };
1401
+ }
1402
+ return { kind: "healthy" };
1403
+ }
1404
+
1405
+ // src/lib/ollama.ts
1406
+ var OLLAMA_URL = "http://localhost:11434";
1407
+ var DEFAULT_MODELS = ["qwen3.5:9b", "nomic-embed-text"];
1408
+ var PROBE_ALPINE_TAG = "3.20";
1409
+ async function startOllamaService() {
1410
+ const res = await safeExeca("brew", ["services", "start", "ollama"]);
1411
+ if (res === null) return { ok: false, reason: "`brew` not on PATH" };
1412
+ if (res.exitCode !== 0) {
1413
+ return {
1414
+ ok: false,
1415
+ reason: `brew services start ollama failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
1416
+ };
1417
+ }
1418
+ return { ok: true };
1419
+ }
1420
+ async function checkOllamaHealth(url = OLLAMA_URL) {
1421
+ try {
1422
+ const res = await fetch(`${url}/api/tags`);
1423
+ if (!res.ok) return { reachable: false, models: [] };
1424
+ const body = await res.json();
1425
+ const models = (body.models ?? []).map((m) => m.name ?? "").filter((n) => n.length > 0);
1426
+ return { reachable: true, models };
1427
+ } catch {
1428
+ return { reachable: false, models: [] };
1429
+ }
1430
+ }
1431
+ async function pullModel(name) {
1432
+ const res = await safeExeca("ollama", ["pull", name]);
1433
+ if (res === null) return { ok: false, reason: "`ollama` not on PATH" };
1434
+ if (res.exitCode !== 0) {
1435
+ return {
1436
+ ok: false,
1437
+ reason: `ollama pull ${name} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
1438
+ };
1439
+ }
1440
+ return { ok: true };
1441
+ }
1442
+ async function warmModel(name, url = OLLAMA_URL) {
1443
+ try {
1444
+ const res = await fetch(`${url}/api/generate`, {
1445
+ method: "POST",
1446
+ headers: { "Content-Type": "application/json" },
1447
+ body: JSON.stringify({ model: name, prompt: "hi", stream: false })
1448
+ });
1449
+ if (!res.ok) {
1450
+ const text6 = await res.text().catch(() => "");
1451
+ return {
1452
+ ok: false,
1453
+ reason: `warm ${name} returned HTTP ${res.status}: ${text6 || "(no body)"}`
1454
+ };
1455
+ }
1456
+ await res.text();
1457
+ return { ok: true };
1458
+ } catch (err) {
1459
+ return {
1460
+ ok: false,
1461
+ reason: `warm ${name} failed: ${err.message}`
1462
+ };
1463
+ }
1464
+ }
1465
+ async function probeHostDockerInternal() {
1466
+ const res = await safeExeca("docker", [
1467
+ "run",
1468
+ "--rm",
1469
+ `alpine:${PROBE_ALPINE_TAG}`,
1470
+ "sh",
1471
+ "-c",
1472
+ "apk add --no-cache curl >/dev/null 2>&1 && curl -fsS http://host.docker.internal:11434/api/tags"
1473
+ ]);
1474
+ if (res === null) return { ok: false, reason: "`docker` not on PATH" };
1475
+ if (res.exitCode !== 0) {
1476
+ return {
1477
+ ok: false,
1478
+ reason: `container couldn't reach host.docker.internal:11434 \u2014 check Docker Desktop settings \u2192 Resources \u2192 Network \u2192 Enable host.docker.internal`
1479
+ };
1480
+ }
1481
+ return { ok: true };
1482
+ }
1483
+ async function setupModels(models, onProgress) {
1484
+ const report = {
1485
+ pulled: [],
1486
+ alreadyPresent: [],
1487
+ failed: [],
1488
+ warmed: []
1489
+ };
1490
+ const health = await checkOllamaHealth();
1491
+ const present = new Set(health.models);
1492
+ for (const m of models) {
1493
+ if (present.has(m)) {
1494
+ report.alreadyPresent.push(m);
1495
+ onProgress?.(m, "present");
1496
+ continue;
1497
+ }
1498
+ onProgress?.(m, "pulling");
1499
+ const r = await pullModel(m);
1500
+ if (r.ok) {
1501
+ report.pulled.push(m);
1502
+ onProgress?.(m, "pulled");
1503
+ } else {
1504
+ report.failed.push({ model: m, reason: r.reason ?? "unknown" });
1505
+ onProgress?.(m, "failed");
1506
+ }
1507
+ }
1508
+ const warmTarget = models.find((m) => present.has(m) || report.pulled.includes(m));
1509
+ if (warmTarget) {
1510
+ onProgress?.(warmTarget, "warming");
1511
+ const w = await warmModel(warmTarget);
1512
+ if (w.ok) {
1513
+ report.warmed.push(warmTarget);
1514
+ onProgress?.(warmTarget, "warmed");
1515
+ }
1516
+ }
1517
+ return report;
1518
+ }
1519
+ var NODE_REPO_MARKERS = [
1520
+ "docker-compose-prod.yml",
1521
+ "supabase/init/pgsodium_getkey_env.sh"
1522
+ ];
1523
+ async function fileExists(path) {
1524
+ try {
1525
+ await access(path);
1526
+ return true;
1527
+ } catch {
1528
+ return false;
1529
+ }
1530
+ }
1531
+ async function looksLikeNodeRepo(dir) {
1532
+ const checks = await Promise.all(
1533
+ NODE_REPO_MARKERS.map((m) => fileExists(join(dir, m)))
1534
+ );
1535
+ return checks.every((ok) => ok);
1536
+ }
1537
+ async function locateOrCloneRepo(input) {
1538
+ if (input.explicit) {
1539
+ if (await looksLikeNodeRepo(input.explicit)) {
1540
+ return { kind: "found", path: input.explicit, source: "explicit" };
1541
+ }
1542
+ return { kind: "explicit-not-a-node-repo", path: input.explicit };
1543
+ }
1544
+ const cwd = input.cwd ?? process.cwd();
1545
+ if (await looksLikeNodeRepo(cwd)) {
1546
+ return { kind: "found", path: cwd, source: "cwd" };
1547
+ }
1548
+ if (input.allowClone === false) {
1549
+ return { kind: "clone-disallowed" };
1550
+ }
1551
+ const target = input.cloneInto ?? join(homedir(), "Development", input.name);
1552
+ const res = await safeExeca("gh", ["repo", "clone", `${input.owner}/${input.name}`, target]);
1553
+ if (res === null) return { kind: "gh-not-installed" };
1554
+ if (res.exitCode !== 0) {
1555
+ return {
1556
+ kind: "clone-failed",
1557
+ reason: `gh repo clone failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
1558
+ };
1559
+ }
1560
+ return { kind: "cloned", path: target };
1561
+ }
1562
+
1563
+ // src/commands/bootstrap.ts
1564
+ var bootstrapCommand = new Command("bootstrap").description(
1565
+ "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("--vault <vault>", "1Password vault to read secrets from").default("Private")).addOption(new Option("--repo-dir <path>", "Explicit path to a checked-out node repo (overrides cwd + clone)")).addOption(
1567
+ new Option(
1568
+ "--compose-file <path>",
1569
+ "Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml + docker-compose-backup.yml"
1570
+ ).default(["docker-compose-prod.yml", "docker-compose-backup.yml"])
1571
+ ).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
+ new Option(
1573
+ "--skip-launch-agent",
1574
+ "Skip the LaunchAgent setup (assumes one is already in place)"
1575
+ ).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(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1577
+ p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
1578
+ const region = opts.region ? opts.region : unwrap(
1579
+ await p3.text({
1580
+ message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
1581
+ placeholder: "us-ca",
1582
+ validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
1583
+ })
1584
+ );
1585
+ const owner = opts.owner ?? "OpusPopuli";
1586
+ const repoName = `opuspopuli-node-${region}`;
1587
+ const sysSpin = p3.spinner();
1588
+ sysSpin.start("Inspecting macOS\u2026");
1589
+ const snap = await inspectSystem();
1590
+ sysSpin.stop(pc2.green("\u2713 macOS inspected."));
1591
+ if (!snap.isAppleSilicon) {
1592
+ p3.cancel(
1593
+ `Bootstrap requires an Apple Silicon Mac (the runbook targets M-series). Detected: ${snap.osVersion ?? "unknown"} (uname -m != arm64).`
1594
+ );
1595
+ process.exit(1);
1596
+ }
1597
+ p3.note(
1598
+ [
1599
+ `Hostname: ${pc2.cyan(snap.hostname)}`,
1600
+ `macOS: ${pc2.cyan(snap.osVersion ?? "(sw_vers failed)")}`,
1601
+ `Auto-restart on power: ${kvBool(snap.autoRestartOnPowerFailure)}`,
1602
+ `Disk sleep disabled: ${kvBool(snap.diskSleepDisabled)}`,
1603
+ `FileVault: ${kvFileVault(snap.fileVaultEnabled)}`
1604
+ ].join("\n"),
1605
+ "System snapshot"
1606
+ );
1607
+ if (!snap.autoRestartOnPowerFailure) {
1608
+ const fix = unwrap(
1609
+ await p3.confirm({
1610
+ message: "Auto-restart-on-power-failure is OFF. Enable now (requires sudo)?",
1611
+ initialValue: true
1612
+ })
1613
+ );
1614
+ if (fix) {
1615
+ const r = await enableAutoRestartOnPowerFailure();
1616
+ if (!r.ok) p3.note(`${pc2.red("\u2717")} ${r.reason}`, "pmset failed");
1617
+ }
1618
+ }
1619
+ if (!snap.diskSleepDisabled) {
1620
+ const fix = unwrap(
1621
+ await p3.confirm({
1622
+ message: "Disk sleep is enabled. Disable now (requires sudo)?",
1623
+ initialValue: true
1624
+ })
1625
+ );
1626
+ if (fix) {
1627
+ const r = await disableDiskSleep();
1628
+ if (!r.ok) p3.note(`${pc2.red("\u2717")} ${r.reason}`, "pmset failed");
1629
+ }
1630
+ }
1631
+ if (!opts.skipBrew) {
1632
+ const brewInfo = await detectBrew();
1633
+ if (!brewInfo.installed) {
1634
+ p3.note(
1635
+ [
1636
+ `Homebrew is not installed. Open another shell and run:`,
1637
+ "",
1638
+ pc2.cyan(HOMEBREW_INSTALL_COMMAND),
1639
+ "",
1640
+ pc2.dim("Then come back and press Enter.")
1641
+ ].join("\n"),
1642
+ "Manual step"
1643
+ );
1644
+ unwrap(await p3.confirm({ message: "Homebrew installed?", initialValue: true }));
1645
+ }
1646
+ const brewSpin = p3.spinner();
1647
+ brewSpin.start("Installing Studio packages\u2026");
1648
+ const report = await installPackages(STUDIO_PACKAGES, (pkg, status) => {
1649
+ brewSpin.message(`${status}: ${pkg.name}`);
1650
+ });
1651
+ brewSpin.stop(
1652
+ report.failed.length === 0 ? pc2.green(
1653
+ `\u2713 ${report.installed.length} installed, ${report.alreadyPresent.length} already present`
1654
+ ) : pc2.yellow(
1655
+ `\u26A0 ${report.installed.length} installed, ${report.alreadyPresent.length} present, ${report.failed.length} failed`
1656
+ )
1657
+ );
1658
+ if (report.failed.length > 0) {
1659
+ p3.note(
1660
+ [
1661
+ report.failed.map((f) => `${pc2.red("\u2022")} ${f.pkg.name}: ${f.reason}`).join("\n"),
1662
+ "",
1663
+ pc2.dim("A partial install usually fails downstream (Docker / Ollama / compose)."),
1664
+ pc2.dim("Install the failed packages manually and re-run with --skip-brew.")
1665
+ ].join("\n"),
1666
+ "Brew failures"
1667
+ );
1668
+ const cont = unwrap(
1669
+ await p3.confirm({
1670
+ message: "Continue anyway? (Default no \u2014 recommended to fix and re-run.)",
1671
+ initialValue: false
1672
+ })
1673
+ );
1674
+ if (!cont) process.exit(1);
1675
+ }
1676
+ }
1677
+ await ensureGhAuth();
1678
+ await promptTailscaleSignin();
1679
+ const repoSpin = p3.spinner();
1680
+ repoSpin.start("Locating your region node repo\u2026");
1681
+ const located = await locateOrCloneRepo({
1682
+ owner,
1683
+ name: repoName,
1684
+ cwd: process.cwd(),
1685
+ ...opts.repoDir ? { explicit: opts.repoDir } : {}
1686
+ });
1687
+ const repoPath = repoPathFromLocate(located);
1688
+ if (!repoPath) {
1689
+ repoSpin.stop(pc2.red("\u2717 Couldn't locate or clone the region repo."));
1690
+ handleLocateError(located, owner, repoName);
1691
+ process.exit(1);
1692
+ }
1693
+ repoSpin.stop(
1694
+ located.kind === "cloned" ? pc2.green(`\u2713 Cloned ${owner}/${repoName} to ${repoPath}`) : pc2.green(`\u2713 Found region repo at ${repoPath}`)
1695
+ );
1696
+ const op = await detectOp();
1697
+ if (!op.installed || !op.signedIn) {
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);
1716
+ p3.cancel(
1717
+ `Missing in 1Password (vault: ${opts.vault ?? "Private"}): ${missing.join(", ")}. Run \`create-op-node init\` first, or pass --vault if your items live elsewhere.`
1718
+ );
1719
+ process.exit(1);
1720
+ }
1721
+ if (!PGSODIUM_KEY_RE.test(pgsodiumKey)) {
1722
+ opSpin.stop(pc2.red("\u2717 pgsodium item exists but isn't a 64-hex key."));
1723
+ p3.cancel("Inspect the item in 1Password; it should be 64 lowercase hex characters.");
1724
+ process.exit(1);
1725
+ }
1726
+ opSpin.stop(pc2.green("\u2713 Secrets read from 1Password."));
1727
+ if (!opts.skipLaunchAgent) {
1728
+ const laSpin = p3.spinner();
1729
+ laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
1730
+ const la = await setupLaunchAgent({ pgsodiumKey, tunnelToken });
1731
+ if (!la.ok) {
1732
+ laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
1733
+ p3.cancel(la.reason ?? "LaunchAgent setup failed.");
1734
+ process.exit(1);
1735
+ }
1736
+ laSpin.stop(pc2.green(`\u2713 LaunchAgent loaded (${la.paths.plistFile}).`));
1737
+ }
1738
+ const ghcrSpin = p3.spinner();
1739
+ ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
1740
+ const ghcr = await loginToGhcr();
1741
+ if (!ghcr.ok) {
1742
+ ghcrSpin.stop(pc2.red("\u2717 ghcr.io login failed."));
1743
+ p3.cancel(ghcr.reason ?? "ghcr.io login failed.");
1744
+ process.exit(1);
1745
+ }
1746
+ ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
1747
+ if (!opts.skipOllama) {
1748
+ const olSpin = p3.spinner();
1749
+ olSpin.start("Pulling + warming Ollama models\u2026");
1750
+ let olHealth = await checkOllamaHealth();
1751
+ if (!olHealth.reachable) {
1752
+ olSpin.message("Ollama not reachable \u2014 running `brew services start ollama`\u2026");
1753
+ const start = await startOllamaService();
1754
+ if (!start.ok) {
1755
+ olSpin.stop(pc2.red("\u2717 Couldn't start Ollama service."));
1756
+ p3.cancel(
1757
+ `${start.reason ?? "unknown"} \u2014 start it manually with \`brew services start ollama\` and re-run with --skip-brew --skip-launch-agent.`
1758
+ );
1759
+ process.exit(1);
1760
+ }
1761
+ await new Promise((res) => setTimeout(res, 3e3));
1762
+ olHealth = await checkOllamaHealth();
1763
+ if (!olHealth.reachable) {
1764
+ olSpin.stop(
1765
+ pc2.yellow("\u26A0 Ollama service started but daemon not yet answering on :11434.")
1766
+ );
1767
+ p3.cancel(
1768
+ "Give it another few seconds, then re-run with --skip-brew --skip-launch-agent."
1769
+ );
1770
+ process.exit(1);
1771
+ }
1772
+ }
1773
+ const modelReport = await setupModels(DEFAULT_MODELS, (model, status) => {
1774
+ olSpin.message(`${status}: ${model}`);
1775
+ });
1776
+ olSpin.stop(
1777
+ modelReport.failed.length === 0 ? pc2.green(
1778
+ `\u2713 ${modelReport.pulled.length} pulled, ${modelReport.alreadyPresent.length} present, ${modelReport.warmed.length} warmed`
1779
+ ) : pc2.yellow(`\u26A0 ${modelReport.failed.length} model pull(s) failed`)
1780
+ );
1781
+ const probe = await probeHostDockerInternal();
1782
+ if (!probe.ok) {
1783
+ p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
1784
+ }
1785
+ }
1786
+ if (opts.skipStack) {
1787
+ p3.outro(
1788
+ pc2.cyan(
1789
+ `Stack-up skipped. Run \`docker compose -f ${(opts.composeFile ?? ["docker-compose-prod.yml"]).join(" -f ")} pull && up -d\` from ${repoPath} when ready.`
1790
+ )
1791
+ );
1792
+ return;
1793
+ }
1794
+ const composeFiles = resolveComposeFiles(repoPath, opts.composeFile);
1795
+ const composeOpts = {
1796
+ files: composeFiles,
1797
+ cwd: repoPath,
1798
+ ...opts.envFile ? { envFile: opts.envFile } : {}
1799
+ };
1800
+ const pullSpin = p3.spinner();
1801
+ pullSpin.start("Pulling images from ghcr.io\u2026");
1802
+ const pull = await composePull(composeOpts);
1803
+ if (!pull.ok) {
1804
+ pullSpin.stop(pc2.red("\u2717 compose pull failed."));
1805
+ p3.cancel(pull.reason ?? "compose pull failed.");
1806
+ process.exit(1);
1807
+ }
1808
+ pullSpin.stop(pc2.green("\u2713 Images pulled."));
1809
+ const upSpin = p3.spinner();
1810
+ upSpin.start("Starting the stack\u2026");
1811
+ const up = await composeUp(composeOpts);
1812
+ if (!up.ok) {
1813
+ upSpin.stop(pc2.red("\u2717 compose up failed."));
1814
+ p3.cancel(up.reason ?? "compose up failed.");
1815
+ process.exit(1);
1816
+ }
1817
+ upSpin.stop(pc2.green("\u2713 Containers started \u2014 waiting for healthy\u2026"));
1818
+ const healthSpin = p3.spinner();
1819
+ healthSpin.start("Polling for healthy containers (5s, up to 5 minutes)\u2026");
1820
+ const outcome = await waitForHealthy(composeOpts, {
1821
+ onPoll: (snaps) => {
1822
+ const healthy = snaps.filter((s) => s.health === "healthy").length;
1823
+ const running = snaps.filter((s) => s.state === "running").length;
1824
+ healthSpin.message(`${healthy}/${running} healthy`);
1825
+ }
1826
+ });
1827
+ switch (outcome.kind) {
1828
+ case "healthy":
1829
+ healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
1830
+ p3.outro(
1831
+ pc2.cyan(
1832
+ `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1833
+ )
1834
+ );
1835
+ return;
1836
+ case "unhealthy":
1837
+ healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
1838
+ p3.note(
1839
+ outcome.snapshots.map((s) => ` ${kvHealth(s.health)} ${s.name} (${s.state})`).join("\n"),
1840
+ "Container state"
1841
+ );
1842
+ p3.cancel(
1843
+ `Inspect with \`docker compose -f ${composeFiles.join(" -f ")} logs\`. Fix and re-run.`
1844
+ );
1845
+ process.exit(1);
1846
+ // eslint-disable-next-line no-fallthrough
1847
+ case "timeout":
1848
+ healthSpin.stop(pc2.yellow("\u26A0 Timed out waiting for all containers to report healthy."));
1849
+ p3.note(
1850
+ outcome.snapshots.map((s) => ` ${kvHealth(s.health)} ${s.name} (${s.state})`).join("\n"),
1851
+ "Container state at timeout"
1852
+ );
1853
+ p3.cancel(
1854
+ `Re-run \`create-op-node bootstrap --skip-brew --skip-launch-agent --skip-ollama\` to skip the already-done phases and poll again once containers settle.`
1855
+ );
1856
+ process.exit(1);
1857
+ // eslint-disable-next-line no-fallthrough
1858
+ default: {
1859
+ return;
1860
+ }
1861
+ }
1862
+ });
1863
+ function resolveComposeFiles(repoPath, composeFile) {
1864
+ const inputs = composeFile ?? ["docker-compose-prod.yml"];
1865
+ return inputs.map((f) => f.startsWith("/") ? f : join(repoPath, f));
1866
+ }
1867
+ function kvBool(b) {
1868
+ return b ? pc2.green("on") : pc2.yellow("off");
1869
+ }
1870
+ function kvFileVault(v) {
1871
+ if (v === "unknown") return pc2.dim("unknown");
1872
+ return v ? pc2.green("on") : pc2.dim("off");
1873
+ }
1874
+ function kvHealth(h) {
1875
+ switch (h) {
1876
+ case "healthy":
1877
+ return pc2.green("\u2713");
1878
+ case "unhealthy":
1879
+ return pc2.red("\u2717");
1880
+ case "starting":
1881
+ return pc2.yellow("\u2026");
1882
+ case "none":
1883
+ return pc2.dim("-");
1884
+ }
1885
+ }
1886
+ function repoPathFromLocate(out) {
1887
+ if (out.kind === "found" || out.kind === "cloned") return out.path;
1888
+ return null;
1889
+ }
1890
+ function handleLocateError(out, owner, name) {
1891
+ switch (out.kind) {
1892
+ case "explicit-not-a-node-repo":
1893
+ p3.cancel(
1894
+ `${out.path} doesn't look like a node repo (missing one of docker-compose-prod.yml / supabase/init/pgsodium_getkey_env.sh). Point --repo-dir at the right place.`
1895
+ );
1896
+ return;
1897
+ case "gh-not-installed":
1898
+ p3.cancel("`gh` not installed \u2014 install via `brew install gh` and re-run.");
1899
+ return;
1900
+ case "clone-failed":
1901
+ p3.cancel(
1902
+ `Couldn't clone ${owner}/${name}: ${out.reason}. Check repo exists + you have access.`
1903
+ );
1904
+ return;
1905
+ case "clone-disallowed":
1906
+ p3.cancel(
1907
+ "No checked-out node repo found and --no-clone (or similar) was set. Provide --repo-dir."
1908
+ );
1909
+ return;
1910
+ case "found":
1911
+ case "cloned":
1912
+ return;
1913
+ default: {
1914
+ return;
1915
+ }
1916
+ }
1917
+ }
1918
+ async function ensureGhAuth() {
1919
+ const status = await safeExeca("gh", ["auth", "status"]);
1920
+ if (status === null) {
1921
+ p3.cancel("`gh` not on PATH. Install via `brew install gh` and re-run.");
1922
+ process.exit(1);
1923
+ }
1924
+ if (status.exitCode === 0) return;
1925
+ const detail = (status.stderr || status.stdout).trim();
1926
+ p3.note(
1927
+ [
1928
+ "`gh` is not signed in (or signed in to a different account / missing scope).",
1929
+ detail ? "" : void 0,
1930
+ detail ? pc2.dim(detail) : void 0,
1931
+ "",
1932
+ "In another shell:",
1933
+ "",
1934
+ pc2.cyan("gh auth login --web"),
1935
+ "",
1936
+ pc2.dim("Then come back and press Enter.")
1937
+ ].filter((l) => typeof l === "string").join("\n"),
1938
+ "Manual step"
1939
+ );
1940
+ unwrap(await p3.confirm({ message: "gh signed in?", initialValue: true }));
1941
+ }
1942
+ async function promptTailscaleSignin() {
1943
+ const status = await safeExeca("tailscale", ["status"]);
1944
+ if (status !== null && status.exitCode === 0) return;
1945
+ p3.note(
1946
+ [
1947
+ "Tailscale needs to be signed in (or you can skip \u2014 Tailscale is",
1948
+ "optional; it provides out-of-band SSH from your laptop. The rest",
1949
+ "of the bootstrap continues without it).",
1950
+ "",
1951
+ "In another shell:",
1952
+ "",
1953
+ pc2.cyan("tailscale up"),
1954
+ "",
1955
+ pc2.dim("Accept the device on your tailnet, then come back and press Enter.")
1956
+ ].join("\n"),
1957
+ "Manual step"
1958
+ );
1959
+ unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
1960
+ }
1961
+ var REGION_RE = /^[a-z0-9-]{2,32}$/;
1962
+ var RESET_PHASES = {
1963
+ STOP_STACK: "Stop stack",
1964
+ LAUNCH_AGENT: "LaunchAgent",
1965
+ DOCKER_LOGOUT: "docker logout"
1966
+ };
1967
+ var DEFAULT_DEPS = {
1968
+ composeDown,
1969
+ teardownLaunchAgent,
1970
+ dockerLogout
1971
+ };
1972
+ async function runReset(input, deps = DEFAULT_DEPS) {
1973
+ const phases = [];
1974
+ const push = (ph) => {
1975
+ phases.push(ph);
1976
+ deps.onPhase?.(ph);
1977
+ };
1978
+ if (!input.stack) {
1979
+ push({
1980
+ name: RESET_PHASES.STOP_STACK,
1981
+ status: "skipped",
1982
+ detail: "--skip-stack or no repo path resolved"
1983
+ });
1984
+ } else if (input.dryRun) {
1985
+ push({
1986
+ name: RESET_PHASES.STOP_STACK,
1987
+ status: "dry-run",
1988
+ detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}`
1989
+ });
1990
+ } else {
1991
+ const result2 = await deps.composeDown({
1992
+ files: input.stack.composeFiles,
1993
+ cwd: input.stack.repoPath,
1994
+ ...input.stack.envFile ? { envFile: input.stack.envFile } : {},
1995
+ wipeVolumes: input.stack.wipeVolumes,
1996
+ removeOrphans: input.stack.removeOrphans
1997
+ });
1998
+ if (result2.ok) {
1999
+ push({
2000
+ name: RESET_PHASES.STOP_STACK,
2001
+ status: "ok",
2002
+ detail: input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved"
2003
+ });
2004
+ } else {
2005
+ push({ name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" });
2006
+ }
2007
+ }
2008
+ if (!input.launchAgent) {
2009
+ push({ name: RESET_PHASES.LAUNCH_AGENT, status: "skipped", detail: "--skip-launch-agent or no plist found" });
2010
+ } else if (input.dryRun) {
2011
+ push({
2012
+ name: RESET_PHASES.LAUNCH_AGENT,
2013
+ status: "dry-run",
2014
+ detail: `would unload ${input.launchAgent.paths.plistFile}, rm plist${input.launchAgent.keepKeyFile ? "" : " + key file"}`
2015
+ });
2016
+ } else {
2017
+ const result2 = await deps.teardownLaunchAgent(
2018
+ input.launchAgent.paths,
2019
+ { keepKeyFile: input.launchAgent.keepKeyFile }
2020
+ );
2021
+ if (result2.ok) {
2022
+ const removed = result2.steps.filter((s) => s.step !== "unload").map((s) => s.step).join(", ");
2023
+ push({ name: RESET_PHASES.LAUNCH_AGENT, status: "ok", detail: `unloaded + ${removed}` });
2024
+ } else {
2025
+ const fail = result2.steps.find((s) => !s.ok);
2026
+ push({
2027
+ name: RESET_PHASES.LAUNCH_AGENT,
2028
+ status: "fail",
2029
+ detail: `${fail?.step}: ${fail?.reason ?? "unknown"}`
2030
+ });
2031
+ }
2032
+ }
2033
+ if (!input.dockerLogout) {
2034
+ push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "skipped", detail: "--skip-docker-logout" });
2035
+ } else if (input.dryRun) {
2036
+ push({
2037
+ name: RESET_PHASES.DOCKER_LOGOUT,
2038
+ status: "dry-run",
2039
+ detail: `would run: docker logout ${input.dockerLogout.registry}`
2040
+ });
2041
+ } else {
2042
+ const result2 = await deps.dockerLogout(input.dockerLogout.registry);
2043
+ if (result2.ok) {
2044
+ push({
2045
+ name: RESET_PHASES.DOCKER_LOGOUT,
2046
+ status: "ok",
2047
+ detail: `credentials removed from ${input.dockerLogout.registry}`
2048
+ });
2049
+ } else {
2050
+ push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "warn", detail: result2.reason ?? "unknown" });
2051
+ }
2052
+ }
2053
+ return { phases };
2054
+ }
2055
+ async function fileExists2(path) {
2056
+ try {
2057
+ await access(path);
2058
+ return true;
2059
+ } catch {
2060
+ return false;
2061
+ }
2062
+ }
2063
+ function phaseIcon(ph) {
2064
+ switch (ph.status) {
2065
+ case "ok":
2066
+ return pc2.green("\u2713");
2067
+ case "warn":
2068
+ return pc2.yellow("\u26A0");
2069
+ case "skipped":
2070
+ return pc2.dim("\xB7");
2071
+ case "dry-run":
2072
+ return pc2.cyan("?");
2073
+ case "fail":
2074
+ return pc2.red("\u2717");
2075
+ }
2076
+ }
2077
+ var resetCommand = new Command("reset").description(
2078
+ "Reverse the Mac Studio side of bootstrap: stop containers, unload the LaunchAgent, remove the pgsodium key file. Preserves docker volumes by default \u2014 pass --wipe-data for a full data nuke (requires retyping the region label)."
2079
+ ).addOption(new Option("--region <slug>", "Region label (used for --wipe-data confirmation + finding the repo)")).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")).addOption(
2080
+ new Option(
2081
+ "--compose-file <path>",
2082
+ "Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml"
2083
+ ).default(["docker-compose-prod.yml"])
2084
+ ).addOption(new Option("--env-file <path>", "Compose --env-file. Default: .env.production")).addOption(
2085
+ new Option(
2086
+ "--wipe-data",
2087
+ "DESTROYS docker volumes (adds -v to `compose down`). Requires retyping the region label as confirmation. Default off \u2014 volumes preserved."
2088
+ ).default(false)
2089
+ ).addOption(
2090
+ new Option(
2091
+ "--no-remove-orphans",
2092
+ "Do NOT pass --remove-orphans to compose down. Preserves containers from compose files no longer included."
2093
+ )
2094
+ ).addOption(new Option("--skip-stack", "Don't touch docker compose").default(false)).addOption(new Option("--skip-launch-agent", "Don't touch the LaunchAgent").default(false)).addOption(
2095
+ new Option(
2096
+ "--keep-key-file",
2097
+ "Preserve the pgsodium key file (only matters when LaunchAgent teardown runs). Useful as belt-and-suspenders backup before a wipe-data run."
2098
+ ).default(false)
2099
+ ).addOption(new Option("--skip-docker-logout", "Don't run `docker logout`").default(false)).addOption(new Option("--registry <registry>", "Registry to log out of").default(GHCR_REGISTRY)).addOption(new Option("--dry-run", "Show what would happen without acting").default(false)).addOption(new Option("-y, --yes", "Skip non-destructive confirmations (wipe still confirms)").default(false)).action(async (opts) => {
2100
+ p3.intro(pc2.bgCyan(pc2.black(" create-op-node reset ")));
2101
+ const wipeData = opts.wipeData ?? false;
2102
+ const removeOrphans = opts.removeOrphans ?? true;
2103
+ const region = opts.region ? opts.region : unwrap(
2104
+ await p3.text({
2105
+ message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
2106
+ placeholder: "us-ca",
2107
+ validate: (v) => REGION_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
2108
+ })
2109
+ );
2110
+ if (!REGION_RE.test(region)) {
2111
+ p3.cancel(`--region ${JSON.stringify(region)} is not a valid region slug.`);
2112
+ process.exit(2);
2113
+ }
2114
+ const owner = opts.owner ?? "OpusPopuli";
2115
+ const repoName = `opuspopuli-node-${region}`;
2116
+ const launchAgentPaths = defaultPaths();
2117
+ const plistExists = await fileExists2(launchAgentPaths.plistFile);
2118
+ const keyFileExists = await fileExists2(launchAgentPaths.keyFile);
2119
+ let repoPath;
2120
+ let stackSkipReason;
2121
+ if (!opts.skipStack) {
2122
+ const located = await locateOrCloneRepo({
2123
+ owner,
2124
+ name: repoName,
2125
+ cwd: process.cwd(),
2126
+ allowClone: false,
2127
+ ...opts.repoDir ? { explicit: opts.repoDir } : {}
2128
+ });
2129
+ switch (located.kind) {
2130
+ case "found":
2131
+ repoPath = located.path;
2132
+ break;
2133
+ case "explicit-not-a-node-repo":
2134
+ p3.cancel(
2135
+ `--repo-dir ${JSON.stringify(located.path)} doesn't look like a node repo (missing one of: ${NODE_REPO_MARKERS.join(", ")}). Verify the path or drop --repo-dir to let reset search the cwd.`
2136
+ );
2137
+ process.exit(2);
2138
+ break;
2139
+ case "clone-disallowed":
2140
+ stackSkipReason = `no checkout found at ${process.cwd()}; pass --repo-dir to target one`;
2141
+ break;
2142
+ case "cloned":
2143
+ case "gh-not-installed":
2144
+ case "clone-failed":
2145
+ stackSkipReason = `unexpected outcome ${located.kind} from locateOrCloneRepo with allowClone=false`;
2146
+ break;
2147
+ }
2148
+ } else {
2149
+ stackSkipReason = "--skip-stack";
2150
+ }
2151
+ let runningContainers = null;
2152
+ if (repoPath) {
2153
+ runningContainers = await composePs({
2154
+ files: resolveComposeFiles(repoPath, opts.composeFile),
2155
+ ...opts.envFile ? { envFile: opts.envFile } : {}
2156
+ });
2157
+ }
2158
+ p3.note(
2159
+ [
2160
+ `Region: ${pc2.cyan(region)}`,
2161
+ `Repo path: ${repoPath ? pc2.cyan(repoPath) : pc2.dim(`not used (${stackSkipReason ?? "no repo path resolved"})`)}`,
2162
+ `Running containers: ${runningContainers === null ? pc2.dim("unknown") : runningContainers.length === 0 ? pc2.dim("none") : pc2.cyan(`${runningContainers.length} listed by compose ps`)}`,
2163
+ `LaunchAgent plist: ${plistExists ? pc2.cyan(launchAgentPaths.plistFile) : pc2.dim("not present")}`,
2164
+ `pgsodium key file: ${keyFileExists ? pc2.cyan(launchAgentPaths.keyFile) : pc2.dim("not present")}`,
2165
+ `Registry to log out: ${pc2.cyan(opts.registry ?? GHCR_REGISTRY)}`,
2166
+ `Volume policy: ${wipeData ? pc2.red("WIPE") : pc2.green("preserve (default)")}`,
2167
+ `Dry run: ${opts.dryRun ? pc2.yellow("yes") : pc2.dim("no")}`
2168
+ ].join("\n"),
2169
+ "Snapshot"
2170
+ );
2171
+ if (wipeData && !opts.dryRun) {
2172
+ unwrap(
2173
+ await p3.text({
2174
+ message: pc2.red(`Type the region label to confirm WIPING all volumes (the slug you'd use with --region):`),
2175
+ validate: (v) => v === region ? void 0 : `must match the region label exactly`
2176
+ })
2177
+ );
2178
+ } else if (!opts.dryRun && !opts.yes) {
2179
+ const cont = unwrap(
2180
+ await p3.confirm({
2181
+ message: "Proceed with reset? (volumes will be preserved)",
2182
+ initialValue: true
2183
+ })
2184
+ );
2185
+ if (!cont) {
2186
+ p3.cancel("Cancelled.");
2187
+ process.exit(0);
2188
+ }
2189
+ }
2190
+ const stack = repoPath ? {
2191
+ repoPath,
2192
+ composeFiles: resolveComposeFiles(repoPath, opts.composeFile),
2193
+ wipeVolumes: wipeData,
2194
+ removeOrphans,
2195
+ ...opts.envFile ? { envFile: opts.envFile } : {}
2196
+ } : void 0;
2197
+ const launchAgent = plistExists && !opts.skipLaunchAgent ? {
2198
+ paths: launchAgentPaths,
2199
+ keepKeyFile: opts.keepKeyFile ?? false
2200
+ } : void 0;
2201
+ const dockerLogoutInput = !opts.skipDockerLogout ? { registry: opts.registry ?? GHCR_REGISTRY } : void 0;
2202
+ const phaseSpins = /* @__PURE__ */ new Map();
2203
+ const actingPhases = [];
2204
+ if (stack && !opts.dryRun) actingPhases.push(RESET_PHASES.STOP_STACK);
2205
+ if (launchAgent && !opts.dryRun) actingPhases.push(RESET_PHASES.LAUNCH_AGENT);
2206
+ if (dockerLogoutInput && !opts.dryRun) actingPhases.push(RESET_PHASES.DOCKER_LOGOUT);
2207
+ for (const name of actingPhases) {
2208
+ const s = p3.spinner();
2209
+ s.start(`${name}\u2026`);
2210
+ phaseSpins.set(name, s);
2211
+ }
2212
+ const renderPhase = (ph) => {
2213
+ const spin = phaseSpins.get(ph.name);
2214
+ const line = `${phaseIcon(ph)} ${ph.name}: ${pc2.dim(ph.detail)}`;
2215
+ if (spin) {
2216
+ spin.stop(line);
2217
+ phaseSpins.delete(ph.name);
2218
+ } else {
2219
+ p3.log.info(line);
2220
+ }
2221
+ };
2222
+ const report = await runReset(
2223
+ {
2224
+ ...stack ? { stack } : {},
2225
+ ...launchAgent ? { launchAgent } : {},
2226
+ ...dockerLogoutInput ? { dockerLogout: dockerLogoutInput } : {},
2227
+ dryRun: opts.dryRun ?? false
2228
+ },
2229
+ { ...DEFAULT_DEPS, onPhase: renderPhase }
2230
+ );
2231
+ for (const [, spin] of phaseSpins) spin.stop(pc2.dim("\u2014 skipped"));
2232
+ const failed = report.phases.filter((ph) => ph.status === "fail").length;
2233
+ if (failed > 0) {
2234
+ p3.outro(pc2.red(`${failed} step${failed === 1 ? "" : "s"} failed.`));
2235
+ process.exit(1);
2236
+ } else if (opts.dryRun) {
2237
+ p3.outro(pc2.cyan("Dry run complete. Re-run without --dry-run to apply."));
2238
+ } else {
2239
+ p3.outro(
2240
+ pc2.green(
2241
+ wipeData ? `Reset complete \u2014 volumes wiped. Re-run \`create-op-node bootstrap --region ${region}\` to start fresh.` : `Reset complete \u2014 volumes preserved. Re-run \`create-op-node bootstrap --region ${region}\` to bring the stack back up.`
2242
+ )
2243
+ );
2244
+ }
2245
+ });
2246
+
2247
+ // src/lib/cosign.ts
2248
+ var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
2249
+ var DEFAULT_IDENTITY_REGEXP = "^https://github\\.com/OpusPopuli/opuspopuli/\\.github/workflows/.*$";
2250
+ async function cosignVerifyImage(input) {
2251
+ const idRegex = input.certificateIdentityRegexp ?? DEFAULT_IDENTITY_REGEXP;
2252
+ const issuer = input.certificateOidcIssuer ?? COSIGN_OIDC_ISSUER;
2253
+ const res = await safeExeca("cosign", [
2254
+ "verify",
2255
+ "--certificate-identity-regexp",
2256
+ idRegex,
2257
+ "--certificate-oidc-issuer",
2258
+ issuer,
2259
+ input.image
2260
+ ]);
2261
+ if (res === null) {
2262
+ return {
2263
+ ok: false,
2264
+ skipped: true,
2265
+ reason: "`cosign` not on PATH (install with `brew install cosign` and rerun to enable signature checks)"
2266
+ };
2267
+ }
2268
+ if (res.exitCode !== 0) {
2269
+ return {
2270
+ ok: false,
2271
+ skipped: false,
2272
+ reason: `cosign verify ${input.image} failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
2273
+ };
2274
+ }
2275
+ return { ok: true, output: res.stdout || res.stderr };
2276
+ }
2277
+
2278
+ // src/lib/http.ts
2279
+ var GRAPHQL_ACCEPT = "application/graphql-response+json, application/json";
2280
+ async function readBodyCapped(res) {
2281
+ const text6 = await res.text();
2282
+ return text6.slice(0, BODY_PREVIEW_MAX);
2283
+ }
2284
+ async function httpProbe(input) {
2285
+ const expected = input.expectedStatus ?? 200;
2286
+ const timeoutMs = input.timeoutMs ?? VERIFY_NETWORK_TIMEOUT_MS;
2287
+ const fetchImpl = input.fetchImpl ?? fetch;
2288
+ const ctrl = new AbortController();
2289
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2290
+ try {
2291
+ const res = await fetchImpl(input.url, { method: "GET", signal: ctrl.signal });
2292
+ const bodyPreview = await readBodyCapped(res);
2293
+ if (res.status !== expected) {
2294
+ return {
2295
+ ok: false,
2296
+ status: res.status,
2297
+ reason: bodyPreview ? `expected HTTP ${expected}, got ${res.status}: ${bodyPreview}` : `expected HTTP ${expected}, got ${res.status}`
2298
+ };
2299
+ }
2300
+ return { ok: true, status: res.status, bodyPreview };
2301
+ } catch (err) {
2302
+ const reason = err.name === "AbortError" ? `GET ${input.url} timed out after ${timeoutMs}ms` : `GET ${input.url} failed: ${err.message}`;
2303
+ return { ok: false, reason };
2304
+ } finally {
2305
+ clearTimeout(timer);
2306
+ }
2307
+ }
2308
+ async function graphqlProbe(input) {
2309
+ const timeoutMs = input.timeoutMs ?? VERIFY_NETWORK_TIMEOUT_MS;
2310
+ const fetchImpl = input.fetchImpl ?? fetch;
2311
+ const ctrl = new AbortController();
2312
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2313
+ try {
2314
+ const res = await fetchImpl(input.url, {
2315
+ method: "POST",
2316
+ headers: { "Content-Type": "application/json", Accept: GRAPHQL_ACCEPT },
2317
+ body: JSON.stringify({ query: "{ __typename }" }),
2318
+ signal: ctrl.signal
2319
+ });
2320
+ const text6 = await readBodyCapped(res);
2321
+ if (res.status !== 200) {
2322
+ return {
2323
+ ok: false,
2324
+ status: res.status,
2325
+ reason: `expected HTTP 200, got ${res.status}: ${text6}`
2326
+ };
2327
+ }
2328
+ let envelope;
2329
+ try {
2330
+ envelope = JSON.parse(text6);
2331
+ } catch {
2332
+ return {
2333
+ ok: false,
2334
+ status: res.status,
2335
+ reason: `response was not JSON: ${text6}`
2336
+ };
2337
+ }
2338
+ const data = envelope.data;
2339
+ const typename = data?.__typename;
2340
+ if (typeof typename !== "string") {
2341
+ return {
2342
+ ok: false,
2343
+ status: res.status,
2344
+ reason: `response missing { data: { __typename: string } }: ${text6}`
2345
+ };
2346
+ }
2347
+ return { ok: true, typename };
2348
+ } catch (err) {
2349
+ const reason = err.name === "AbortError" ? `POST ${input.url} timed out after ${timeoutMs}ms` : `POST ${input.url} failed: ${err.message}`;
2350
+ return { ok: false, reason };
2351
+ } finally {
2352
+ clearTimeout(timer);
2353
+ }
2354
+ }
2355
+ function first(v) {
2356
+ if (v === void 0) return void 0;
2357
+ return Array.isArray(v) ? v[0] : v;
2358
+ }
2359
+ function tlsHandshake(input) {
2360
+ const host = input.host;
2361
+ const port = input.port ?? 443;
2362
+ const timeoutMs = input.timeoutMs ?? VERIFY_NETWORK_TIMEOUT_MS;
2363
+ const connect2 = input.connect ?? tls.connect;
2364
+ const now = input.now ?? /* @__PURE__ */ new Date();
2365
+ return new Promise((resolve2) => {
2366
+ let socket;
2367
+ let settled = false;
2368
+ const settle = (r) => {
2369
+ if (settled) return;
2370
+ settled = true;
2371
+ socket?.destroy();
2372
+ resolve2(r);
2373
+ };
2374
+ try {
2375
+ socket = connect2({
2376
+ host,
2377
+ port,
2378
+ servername: host,
2379
+ timeout: timeoutMs
2380
+ });
2381
+ } catch (err) {
2382
+ settle({ ok: false, reason: `tls.connect to ${host}:${port} threw: ${err.message}` });
2383
+ return;
2384
+ }
2385
+ socket.once("secureConnect", () => {
2386
+ const cert = socket.getPeerCertificate(false);
2387
+ if (!cert || Object.keys(cert).length === 0) {
2388
+ settle({ ok: false, reason: "TLS connected but peer presented no certificate" });
2389
+ return;
2390
+ }
2391
+ const notAfter = new Date(cert.valid_to);
2392
+ if (Number.isNaN(notAfter.getTime())) {
2393
+ settle({ ok: false, reason: `cert presented an unparseable notAfter: ${cert.valid_to}` });
2394
+ return;
2395
+ }
2396
+ const msPerDay = 1e3 * 60 * 60 * 24;
2397
+ const daysToExpiry = Math.floor((notAfter.getTime() - now.getTime()) / msPerDay);
2398
+ const subject = first(cert.subject?.CN) ?? "<no CN>";
2399
+ const issuer = first(cert.issuer?.O) ?? first(cert.issuer?.CN) ?? "<no issuer>";
2400
+ settle({ ok: true, subject, issuer, daysToExpiry });
2401
+ });
2402
+ socket.once("timeout", () => {
2403
+ settle({ ok: false, reason: `TLS handshake to ${host}:${port} timed out after ${timeoutMs}ms` });
2404
+ });
2405
+ socket.once("error", (err) => {
2406
+ settle({ ok: false, reason: `TLS handshake to ${host}:${port} failed: ${err.message}` });
2407
+ });
2408
+ });
2409
+ }
2410
+
2411
+ // src/commands/verify.ts
2412
+ var DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i;
2413
+ function summarize(report) {
2414
+ let failed = 0;
2415
+ let warned = 0;
2416
+ for (const ph of report.phases) {
2417
+ if (ph.status === "fail") failed++;
2418
+ if (ph.status === "warn") warned++;
2419
+ }
2420
+ return { ok: failed === 0, failed, warned };
2421
+ }
2422
+ function formatExpiry(days) {
2423
+ if (days < 0) return `expired ${-days}d ago`;
2424
+ if (days === 0) return "expires today";
2425
+ return `${days}d to expiry`;
2426
+ }
2427
+ var DEFAULT_DEPS2 = {
2428
+ tls: tlsHandshake,
2429
+ http: httpProbe,
2430
+ graphql: graphqlProbe,
2431
+ tunnel: tunnelStatus,
2432
+ cosign: cosignVerifyImage
2433
+ };
2434
+ async function runVerify(input, deps = DEFAULT_DEPS2) {
2435
+ const phases = [];
2436
+ const push = (ph) => {
2437
+ phases.push(ph);
2438
+ deps.onPhase?.(ph);
2439
+ };
2440
+ const tls2 = await deps.tls({ host: input.apiHost });
2441
+ if (!tls2.ok) {
2442
+ push({ name: "TLS handshake", status: "fail", detail: tls2.reason });
2443
+ } else {
2444
+ const line = `subject=${tls2.subject}, issuer=${tls2.issuer}, ${formatExpiry(tls2.daysToExpiry)}`;
2445
+ const warn = tls2.daysToExpiry < input.certWarnDays;
2446
+ if (warn) {
2447
+ push({
2448
+ name: "TLS handshake",
2449
+ status: "warn",
2450
+ detail: tls2.daysToExpiry < 0 ? line : `${line} (< warn threshold ${input.certWarnDays}d)`
2451
+ });
2452
+ } else {
2453
+ push({ name: "TLS handshake", status: "ok", detail: line });
2454
+ }
2455
+ }
2456
+ const healthUrl = `https://${input.apiHost}/health`;
2457
+ const health = await deps.http({ url: healthUrl });
2458
+ if (health.ok) {
2459
+ push({ name: "GET /health", status: "ok", detail: `HTTP ${health.status}` });
2460
+ } else {
2461
+ push({ name: "GET /health", status: "fail", detail: health.reason });
2462
+ }
2463
+ const gqlUrl = `https://${input.apiHost}/api`;
2464
+ const gql = await deps.graphql({ url: gqlUrl });
2465
+ if (gql.ok) {
2466
+ push({ name: "GraphQL { __typename }", status: "ok", detail: `typename=${gql.typename}` });
2467
+ } else {
2468
+ push({ name: "GraphQL { __typename }", status: "fail", detail: gql.reason });
2469
+ }
2470
+ const cfFields = [
2471
+ ["--cf-token", input.cf?.token],
2472
+ ["--cf-account-id", input.cf?.accountId],
2473
+ ["--tunnel-id", input.cf?.tunnelId]
2474
+ ];
2475
+ const cfSet = cfFields.filter(([, v]) => v !== void 0 && v !== "");
2476
+ if (cfSet.length === 0) {
2477
+ push({
2478
+ name: "Cloudflare Tunnel",
2479
+ status: "skipped",
2480
+ detail: "pass --cf-token + --cf-account-id + --tunnel-id to enable"
2481
+ });
2482
+ } else if (cfSet.length < cfFields.length) {
2483
+ const missing = cfFields.filter(([, v]) => v === void 0 || v === "").map(([name]) => name).join(", ");
2484
+ push({
2485
+ name: "Cloudflare Tunnel",
2486
+ status: "warn",
2487
+ detail: `partial CF config \u2014 missing ${missing}; check skipped`
2488
+ });
2489
+ } else {
2490
+ const tun = await deps.tunnel({
2491
+ token: input.cf.token,
2492
+ accountId: input.cf.accountId,
2493
+ tunnelId: input.cf.tunnelId
2494
+ });
2495
+ if (!tun.ok) {
2496
+ push({ name: "Cloudflare Tunnel", status: "fail", detail: tun.reason });
2497
+ } else if (tun.connections === 0) {
2498
+ push({
2499
+ name: "Cloudflare Tunnel",
2500
+ status: "warn",
2501
+ detail: `status=${tun.status}, 0 connections \u2014 cloudflared on the Studio appears offline`
2502
+ });
2503
+ } else {
2504
+ push({
2505
+ name: "Cloudflare Tunnel",
2506
+ status: "ok",
2507
+ detail: `${tun.connections} connections, status=${tun.status}`
2508
+ });
2509
+ }
2510
+ }
2511
+ if (input.images.length === 0) {
2512
+ push({
2513
+ name: "cosign verify",
2514
+ status: "skipped",
2515
+ detail: "pass --image <ref> (repeatable) to enable"
2516
+ });
2517
+ } else {
2518
+ for (const image of input.images) {
2519
+ const cos = await deps.cosign({ image });
2520
+ if (cos.ok) {
2521
+ push({ name: `cosign verify ${image}`, status: "ok", detail: "signature valid" });
2522
+ } else if (cos.skipped) {
2523
+ push({ name: `cosign verify ${image}`, status: "skipped", detail: cos.reason });
2524
+ } else {
2525
+ push({ name: `cosign verify ${image}`, status: "fail", detail: cos.reason });
2526
+ }
2527
+ }
2528
+ }
2529
+ return { phases };
2530
+ }
2531
+ function readTokenFile(path) {
2532
+ const raw = readFileSync(path, "utf8").trim();
2533
+ if (!raw) throw new Error(`--cf-token-file ${path} was empty`);
2534
+ return raw;
2535
+ }
2536
+ var verifyCommand = new Command("verify").description(
2537
+ "Off-LAN health probe of a live node \u2014 TLS, Apollo Federation reachability, GraphQL smoke. Run from anywhere with internet access."
2538
+ ).addOption(
2539
+ new Option("--domain <domain>", "Domain to probe (e.g. example.org \u2192 checks api.example.org)")
2540
+ ).addOption(new Option("--api-host <host>", "Override the API hostname (default: api.<domain>)")).addOption(new Option("--cf-token <token>", "Cloudflare API token (enables tunnel-status probe)")).addOption(
2541
+ new Option("--cf-token-file <path>", "Read CF token from a file (avoids exposing it via ps)")
2542
+ ).addOption(new Option("--cf-account-id <id>", "Cloudflare account ID (required with --cf-token)")).addOption(
2543
+ new Option("--tunnel-id <id>", "Cloudflare Tunnel ID to query (required with --cf-token)")
2544
+ ).addOption(
2545
+ new Option("--image <ref>", "Repeatable. Image to cosign-verify (e.g. ghcr.io/opuspopuli/api:tag)").default(
2546
+ []
2547
+ )
2548
+ ).addOption(
2549
+ new Option("--cert-warn-days <n>", "Warn if cert expires within N days").default("14")
2550
+ ).addOption(
2551
+ new Option("--show-skipped", "Include skipped phases in the summary").default(false)
2552
+ ).action(async (opts) => {
2553
+ p3.intro(pc2.bgCyan(pc2.black(" create-op-node verify ")));
2554
+ const domain = opts.domain ? opts.domain : unwrap(
2555
+ await p3.text({
2556
+ message: "Public domain of the node?",
2557
+ placeholder: "yournode.example.org",
2558
+ validate: (v) => DOMAIN_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens, dots; must contain a dot and a TLD"
2559
+ })
2560
+ );
2561
+ if (!DOMAIN_RE.test(domain)) {
2562
+ p3.cancel(`--domain ${JSON.stringify(domain)} doesn't look like a domain.`);
2563
+ process.exit(2);
2564
+ }
2565
+ const rawDays = opts.certWarnDays ?? "14";
2566
+ const certWarnDays = Number.parseInt(rawDays, 10);
2567
+ if (!Number.isFinite(certWarnDays) || certWarnDays < 0 || !/^\d+$/.test(rawDays)) {
2568
+ p3.cancel(`--cert-warn-days must be a non-negative integer (got ${JSON.stringify(rawDays)}).`);
2569
+ process.exit(2);
2570
+ }
2571
+ if (opts.cfToken && opts.cfTokenFile) {
2572
+ p3.cancel("Pass either --cf-token or --cf-token-file, not both.");
2573
+ process.exit(2);
2574
+ }
2575
+ let cfToken = opts.cfToken;
2576
+ if (!cfToken && opts.cfTokenFile) {
2577
+ try {
2578
+ cfToken = readTokenFile(opts.cfTokenFile);
2579
+ } catch (err) {
2580
+ p3.cancel(err.message);
2581
+ process.exit(2);
2582
+ }
2583
+ }
2584
+ const apiHost = opts.apiHost ?? `api.${domain}`;
2585
+ const images = opts.image ?? [];
2586
+ const totalPhases = 4 + (images.length === 0 ? 1 : images.length);
2587
+ let phaseIndex = 0;
2588
+ let activeSpin = null;
2589
+ const renderPhase = (ph) => {
2590
+ phaseIndex++;
2591
+ const prefix = `[${phaseIndex}/${totalPhases}] ${ph.name}`;
2592
+ activeSpin?.stop(formatPhaseLine(prefix, ph));
2593
+ activeSpin = null;
2594
+ };
2595
+ const deps = {
2596
+ ...DEFAULT_DEPS2,
2597
+ onPhase: renderPhase
2598
+ };
2599
+ activeSpin = p3.spinner();
2600
+ activeSpin.start("Running checks\u2026");
2601
+ const report = await runVerify(
2602
+ {
2603
+ apiHost,
2604
+ certWarnDays,
2605
+ cf: {
2606
+ ...cfToken ? { token: cfToken } : {},
2607
+ ...opts.cfAccountId ? { accountId: opts.cfAccountId } : {},
2608
+ ...opts.tunnelId ? { tunnelId: opts.tunnelId } : {}
2609
+ },
2610
+ images
2611
+ },
2612
+ deps
2613
+ );
2614
+ activeSpin?.stop("Done.");
2615
+ const visible = opts.showSkipped ?? false ? report.phases : report.phases.filter((ph) => ph.status !== "skipped");
2616
+ const lines = visible.map((ph, i) => {
2617
+ const idx = report.phases.indexOf(ph) + 1;
2618
+ return `${phaseIcon2(ph)} [${idx}/${totalPhases}] ${ph.name}: ${pc2.dim(ph.detail)}`;
2619
+ });
2620
+ const skippedCount = report.phases.length - visible.length;
2621
+ if (skippedCount > 0 && !opts.showSkipped) {
2622
+ lines.push(pc2.dim(`\xB7 ${skippedCount} phase${skippedCount === 1 ? "" : "s"} skipped (run with --show-skipped to see them)`));
2623
+ }
2624
+ p3.note(lines.join("\n"), "Summary");
2625
+ const summary = summarize(report);
2626
+ if (summary.ok) {
2627
+ p3.outro(
2628
+ summary.warned === 0 ? pc2.green("All checks passed.") : pc2.yellow(`Passed with ${summary.warned} warning${summary.warned === 1 ? "" : "s"}.`)
2629
+ );
2630
+ } else {
2631
+ p3.outro(pc2.red(`${summary.failed} check${summary.failed === 1 ? "" : "s"} failed.`));
2632
+ process.exit(1);
2633
+ }
2634
+ });
2635
+ function phaseIcon2(ph) {
2636
+ switch (ph.status) {
2637
+ case "ok":
2638
+ return pc2.green("\u2713");
2639
+ case "warn":
2640
+ return pc2.yellow("\u26A0");
2641
+ case "skipped":
2642
+ return pc2.dim("\xB7");
2643
+ case "fail":
2644
+ return pc2.red("\u2717");
2645
+ }
2646
+ }
2647
+ function formatPhaseLine(prefix, ph) {
2648
+ const icon = phaseIcon2(ph);
2649
+ const colorize = ph.status === "ok" ? pc2.green : ph.status === "warn" ? pc2.yellow : ph.status === "skipped" ? pc2.dim : pc2.red;
2650
+ return colorize(`${icon} ${prefix}: ${ph.detail}`);
2651
+ }
2652
+
2653
+ // src/lib/region-schema.json
2654
+ var region_schema_default = {
2655
+ $schema: "http://json-schema.org/draft-07/schema#",
2656
+ $id: "https://github.com/OpusPopuli/opuspopuli-regions/schema/region-plugin.schema.json",
2657
+ title: "Region Plugin File",
2658
+ description: "A declarative region configuration file for the Opus Populi civic data platform. A handful of string-valued fields (ApiSourceConfig.queryParams, BulkDownloadConfig.filters) support ${variableName} placeholders that the consumer resolves at runtime \u2014 see those field descriptions for the supported variable list.",
2659
+ type: "object",
2660
+ required: ["name", "displayName", "description", "version", "config"],
2661
+ additionalProperties: false,
2662
+ properties: {
2663
+ name: {
2664
+ type: "string",
2665
+ minLength: 1,
2666
+ description: "Region identifier (e.g., 'california')"
2667
+ },
2668
+ displayName: {
2669
+ type: "string",
2670
+ minLength: 1,
2671
+ description: "Human-readable region name (e.g., 'California')"
2672
+ },
2673
+ description: {
2674
+ type: "string",
2675
+ description: "Brief description of this region's data coverage"
2676
+ },
2677
+ version: {
2678
+ type: "string",
2679
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
2680
+ description: "Semantic version of this config file"
2681
+ },
2682
+ config: {
2683
+ $ref: "#/definitions/DeclarativeRegionConfig"
2684
+ }
2685
+ },
2686
+ definitions: {
2687
+ DeclarativeRegionConfig: {
2688
+ type: "object",
2689
+ required: ["regionId", "regionName", "description", "timezone", "dataSources"],
2690
+ additionalProperties: false,
2691
+ properties: {
2692
+ regionId: {
2693
+ type: "string",
2694
+ minLength: 1,
2695
+ description: "Region identifier (e.g., 'california')"
2696
+ },
2697
+ regionName: {
2698
+ type: "string",
2699
+ minLength: 1,
2700
+ description: "Human-readable region name (e.g., 'California')"
2701
+ },
2702
+ description: {
2703
+ type: "string",
2704
+ description: "Region description"
2705
+ },
2706
+ timezone: {
2707
+ type: "string",
2708
+ description: "IANA timezone (e.g., 'America/Los_Angeles')"
2709
+ },
2710
+ stateCode: {
2711
+ type: "string",
2712
+ pattern: "^[A-Z]{2}$",
2713
+ description: "Two-letter US state code (e.g., 'CA')"
2714
+ },
2715
+ parentRegionId: {
2716
+ type: "string",
2717
+ minLength: 1,
2718
+ description: "Region identifier of this region's parent in the geographic hierarchy. Top-level regions (states, federal) omit this field. Counties set parentRegionId to their state's regionId (e.g., 'california'); cities will set it to their county's regionId. Presence of this field is the canonical signal that a region is a sub-region; the consumer loader uses it to build the region tree."
2719
+ },
2720
+ fipsCode: {
2721
+ type: "string",
2722
+ pattern: "^\\d{2,7}$",
2723
+ description: "Census FIPS code: 2 digits for states (e.g., '06' for CA), 5 digits for counties (e.g., '06037' for Los Angeles County \u2014 state FIPS + county FIPS), 7 digits for places/cities. This is the canonical join key against the consumer's PostGIS counties/places tables used today for user-to-district placement."
2724
+ },
2725
+ dataSources: {
2726
+ type: "array",
2727
+ minItems: 1,
2728
+ items: {
2729
+ $ref: "#/definitions/DataSourceConfig"
2730
+ },
2731
+ description: "Data sources to scrape"
2732
+ },
2733
+ rateLimit: {
2734
+ type: "object",
2735
+ properties: {
2736
+ requestsPerSecond: { type: "number", minimum: 0 },
2737
+ burstSize: { type: "integer", minimum: 1 }
2738
+ },
2739
+ required: ["requestsPerSecond", "burstSize"],
2740
+ additionalProperties: false,
2741
+ description: "Rate limiting defaults"
2742
+ },
2743
+ cacheTtlMs: {
2744
+ type: "integer",
2745
+ minimum: 0,
2746
+ description: "Cache TTL in milliseconds"
2747
+ },
2748
+ requestTimeoutMs: {
2749
+ type: "integer",
2750
+ minimum: 0,
2751
+ description: "Request timeout in milliseconds"
2752
+ },
2753
+ normalizeExternalIdDistrict: {
2754
+ type: "boolean",
2755
+ description: "Strip leading zeros from representative externalId trailing segment (e.g. ca-assembly-01 \u2192 ca-assembly-1). Opt-in; default false. Only needed for regions whose LLM manifests emit zero-padded district numbers."
2756
+ },
2757
+ bioNoisePatterns: {
2758
+ type: "array",
2759
+ items: { type: "string" },
2760
+ description: "Regex pattern strings (case-insensitive) matched against scraped representative bio text. Bios matching any pattern are treated as nav junk and discarded. Defaults to no filtering if omitted."
2761
+ },
2762
+ boundarySources: {
2763
+ $ref: "#/definitions/BoundarySourcesConfig",
2764
+ description: "Civic-boundary geometry sources used to populate the consumer's `jurisdictions` table (counties, cities, state legislative districts, school districts, special districts). The consumer's BoundaryLoaderService reads this block, dispatches to TIGER and ArcGIS FeatureServer fetchers, and upserts on fipsCode/ocdId. Omit when a region has no public boundary data \u2014 addresses still resolve via Census Geocoder string fields, but PostGIS point-in-polygon queries return no matches. See opuspopuli#804."
2765
+ }
2766
+ }
2767
+ },
2768
+ BoundarySourcesConfig: {
2769
+ type: "object",
2770
+ additionalProperties: false,
2771
+ required: ["ocdIdPrefix"],
2772
+ description: "Per-region declarative recipe for loading civic boundaries. Pure config \u2014 no per-region code lives in the consumer.",
2773
+ properties: {
2774
+ ocdIdPrefix: {
2775
+ type: "string",
2776
+ minLength: 1,
2777
+ description: "Open Civic Data ID prefix for this region, e.g. 'ocd-division/country:us/state:ca'. All jurisdiction OCD-IDs are composed as `${ocdIdPrefix}${TigerLayerConfig.ocdIdSegment}` (or the Geoportal equivalent)."
2778
+ },
2779
+ tigerLayers: {
2780
+ type: "array",
2781
+ minItems: 1,
2782
+ items: { $ref: "#/definitions/TigerLayerConfig" },
2783
+ description: "US Census TIGER/Line layers to ingest as jurisdictions. Each entry maps one ArcGIS MapServer layer to a JurisdictionType. Omit the field entirely when the region has no TIGER coverage \u2014 do not pass an empty array (the schema rejects it to prevent silent typos that strip the configured layers)."
2784
+ },
2785
+ geoportalLayers: {
2786
+ type: "array",
2787
+ minItems: 1,
2788
+ items: { $ref: "#/definitions/GeoportalLayerConfig" },
2789
+ description: "Region-specific ArcGIS FeatureServer endpoints \u2014 typically state geoportals serving boundary data Census TIGER doesn't cover (fire districts, water districts, transit districts, etc.). Omit when unused \u2014 empty arrays are rejected (see tigerLayers note)."
2790
+ }
2791
+ }
2792
+ },
2793
+ JurisdictionTypeEnum: {
2794
+ type: "string",
2795
+ enum: ["COUNTY", "CITY", "STATE_SENATE_DISTRICT", "STATE_ASSEMBLY_DISTRICT", "CONGRESSIONAL_DISTRICT", "SCHOOL_DISTRICT_UNIFIED", "SCHOOL_DISTRICT_ELEMENTARY", "SCHOOL_DISTRICT_HIGH", "COMMUNITY_COLLEGE_DISTRICT", "WATER_DISTRICT", "FIRE_DISTRICT", "TRANSIT_DISTRICT", "SPECIAL_DISTRICT"],
2796
+ description: "Maps each feature to a JurisdictionType row in the consumer's `jurisdictions` table. Single source of truth \u2014 TigerLayerConfig and GeoportalLayerConfig both reference this so a new jurisdiction type can't be added to one without the other."
2797
+ },
2798
+ JurisdictionLevelEnum: {
2799
+ type: "string",
2800
+ enum: ["FEDERAL", "STATE", "COUNTY", "MUNICIPAL", "DISTRICT"],
2801
+ description: "Hierarchy level for the resulting jurisdiction rows. Single source of truth \u2014 referenced from TigerLayerConfig and GeoportalLayerConfig."
2802
+ },
2803
+ TigerLayerConfig: {
2804
+ type: "object",
2805
+ additionalProperties: false,
2806
+ required: ["layer", "outFields", "jurisdictionType", "level", "nameField"],
2807
+ description: "One TIGER/Line layer mapped to a JurisdictionType. The consumer hits `https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/${layer}/query` with GeoJSON output. Supported placeholders inside `where`, `ocdIdSegment`, `nameTemplate`: ${fipsCode} (this region's fipsCode), ${stateCode} (two-letter code), ${name} (the layer's nameField value for a given feature), ${district} (the districtField value).",
2808
+ properties: {
2809
+ layer: {
2810
+ type: "string",
2811
+ minLength: 1,
2812
+ description: "TIGER MapServer service+layer path, e.g. 'State_County/MapServer/1', 'Legislative/MapServer/1' (state senate), 'Legislative/MapServer/2' (state assembly), 'School/MapServer/0' (unified school districts)."
2813
+ },
2814
+ where: {
2815
+ type: "string",
2816
+ description: "ESRI WHERE clause filtering features. Defaults to `STATE='${fipsCode}'` when omitted. Use `${fipsCode}` / `${stateCode}` placeholders."
2817
+ },
2818
+ outFields: {
2819
+ type: "string",
2820
+ minLength: 1,
2821
+ description: "Comma-separated TIGER attribute fields to return, e.g. 'GEOID,NAME' or 'GEOID,SLDUST'."
2822
+ },
2823
+ jurisdictionType: {
2824
+ $ref: "#/definitions/JurisdictionTypeEnum"
2825
+ },
2826
+ level: {
2827
+ $ref: "#/definitions/JurisdictionLevelEnum"
2828
+ },
2829
+ nameField: {
2830
+ type: "string",
2831
+ minLength: 1,
2832
+ description: "TIGER attribute key whose value becomes the substitution for `${name}` placeholders. Typically 'NAME' for human-readable layers, or a district code field like 'SLDUST'/'SLDLST' for legislative layers."
2833
+ },
2834
+ fipsField: {
2835
+ type: "string",
2836
+ description: "TIGER attribute key holding the canonical fips_code for an upsert. Defaults to 'GEOID'."
2837
+ },
2838
+ fipsPrefix: {
2839
+ type: "string",
2840
+ description: "Optional prefix prepended to the fipsField value before persisting as fips_code. Use when the raw GEOID would collide with another layer's GEOID (e.g. state senate GEOID overlapping county GEOID format). Example: 'sldu-' for state senate, 'sldl-' for state assembly."
2841
+ },
2842
+ districtField: {
2843
+ type: "string",
2844
+ description: "TIGER attribute key supplying the ${district} placeholder. Required when ocdIdSegment or nameTemplate contains ${district}."
2845
+ },
2846
+ ocdIdSegment: {
2847
+ type: "string",
2848
+ description: "Segment appended to `boundarySources.ocdIdPrefix` to form the full OCD-ID, e.g. '/county:${name}', '/sldu:${district}'. Supports ${name} and ${district} placeholders. Inside `${name}` substitutions the consumer normalizes whitespace to underscores and lowercases the value for OCD-ID compatibility \u2014 so a NAME of 'Alameda County' produces 'alameda_county' in the OCD-ID. Omit when this layer's rows don't carry an OCD-ID."
2849
+ },
2850
+ nameTemplate: {
2851
+ type: "string",
2852
+ description: "Template for the persisted jurisdiction name (NOT for OCD-ID composition). Supports ${name}, ${district}, ${stateCode}. The ${name} value is substituted verbatim (case + whitespace preserved). Defaults to ${name} when omitted."
2853
+ }
2854
+ }
2855
+ },
2856
+ GeoportalLayerConfig: {
2857
+ type: "object",
2858
+ additionalProperties: false,
2859
+ required: ["url", "outFields", "jurisdictionType", "level", "nameField"],
2860
+ description: "One arbitrary ArcGIS FeatureServer layer for jurisdictions Census TIGER doesn't cover. Same placeholder rules as TigerLayerConfig.",
2861
+ properties: {
2862
+ url: {
2863
+ type: "string",
2864
+ format: "uri",
2865
+ pattern: "^https://",
2866
+ description: "Full ArcGIS FeatureServer layer URL ending in /FeatureServer/<n>. Must be HTTPS \u2014 the consumer rejects other schemes. The consumer paginates via resultOffset/resultRecordCount."
2867
+ },
2868
+ where: {
2869
+ type: "string",
2870
+ description: "ESRI WHERE clause. Defaults to '1=1'."
2871
+ },
2872
+ outFields: {
2873
+ type: "string",
2874
+ minLength: 1,
2875
+ description: "Comma-separated FeatureServer attribute fields to return."
2876
+ },
2877
+ jurisdictionType: {
2878
+ $ref: "#/definitions/JurisdictionTypeEnum"
2879
+ },
2880
+ level: {
2881
+ $ref: "#/definitions/JurisdictionLevelEnum"
2882
+ },
2883
+ nameField: {
2884
+ type: "string",
2885
+ minLength: 1,
2886
+ description: "FeatureServer attribute key supplying the human-readable name and the ${name} placeholder."
2887
+ },
2888
+ fipsField: {
2889
+ type: "string",
2890
+ description: "FeatureServer attribute key used as fips_code. No default \u2014 geoportal attribute names vary widely (no GEOID convention). Omit when the layer has no FIPS analog; the upsert then matches on ocdId only."
2891
+ },
2892
+ fipsPrefix: {
2893
+ type: "string",
2894
+ description: "Optional prefix prepended to fipsField, mirroring TigerLayerConfig.fipsPrefix."
2895
+ },
2896
+ ocdIdSegment: {
2897
+ type: "string",
2898
+ description: "Appended to boundarySources.ocdIdPrefix. Supports ${name} placeholder. Same OCD-ID normalization as TigerLayerConfig.ocdIdSegment \u2014 whitespace to underscores, lowercased."
2899
+ },
2900
+ nameTemplate: {
2901
+ type: "string",
2902
+ description: "Template for persisted name (NOT for OCD-ID). ${name} substituted verbatim. Defaults to ${name}."
2903
+ }
2904
+ }
2905
+ },
2906
+ DataSourceConfig: {
2907
+ type: "object",
2908
+ required: ["url", "dataType", "contentGoal"],
2909
+ additionalProperties: false,
2910
+ properties: {
2911
+ url: {
2912
+ type: "string",
2913
+ format: "uri",
2914
+ description: "URL to fetch data from"
2915
+ },
2916
+ dataType: {
2917
+ type: "string",
2918
+ enum: ["propositions", "meetings", "representatives", "campaign_finance", "lobbying", "civics", "bills"],
2919
+ description: "What type of content this source provides. Daily journals / meeting minutes go under 'meetings' with sourceType=pdf_archive \u2014 the consumer partitions by sourceType internally. 'civics' marks a source of region governmental structure, process, and vocabulary; the civics-ingest handler scrapes it and uses the private prompt-service to extract structured shape (chambers, measure types, lifecycle stages with status patterns, glossary) plus a plain-language rewrite for laypeople. Verbatim source text and the AI rewrite are stored together \u2014 never one without the other. 'bills' marks a source of individual legislative bills (AB, SB, ACA, SCA, etc.) scraped from an official legislature website; the bills-ingest handler BFS-crawls from the seed URL and extracts one Bill record per detail page. See opuspopuli#686."
2920
+ },
2921
+ contentGoal: {
2922
+ type: "string",
2923
+ minLength: 1,
2924
+ description: "Natural language description of what to find/extract"
2925
+ },
2926
+ category: {
2927
+ type: "string",
2928
+ description: "Sub-category for grouping (e.g., 'Assembly', 'Senate')"
2929
+ },
2930
+ hints: {
2931
+ type: "array",
2932
+ items: { type: "string" },
2933
+ description: "Hints to help the AI find the right content"
2934
+ },
2935
+ rateLimitOverride: {
2936
+ type: "number",
2937
+ minimum: 0,
2938
+ description: "Override rate limit for this specific source"
2939
+ },
2940
+ syncCadence: {
2941
+ type: "string",
2942
+ pattern: "^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+$",
2943
+ description: "5-field cron expression (minute hour dom month dow) controlling how often this source is synced. The worker applies a deterministic per-source minute offset at registration time to spread load across gov hosts \u2014 the minute field you specify is the base, not the exact fire time. Omit to inherit the global daily fallback. Common patterns: '0 2 * * *' daily at 2 AM, '0 2 * * 0' weekly on Sunday, '0 * * * *' hourly."
2944
+ },
2945
+ statusScanCadence: {
2946
+ type: "string",
2947
+ pattern: "^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+$",
2948
+ description: "5-field cron expression for a periodic full status-page sweep of all bills (dataType='bills' only). Runs forceStatusRecheck=true \u2014 bypasses the needsStatusRecheck flag and the sourcePublishedAt skip to catch governor actions and off-session changes the journal linker can't see. Per-source minute offset is applied like syncCadence. Typical: '0 2 * * 0' weekly Sunday 2 AM. See #689."
2949
+ },
2950
+ sourceType: {
2951
+ type: "string",
2952
+ enum: ["html_scrape", "bulk_download", "api", "pdf", "pdf_archive"],
2953
+ description: "Source type determines the extraction strategy (default: html_scrape). 'pdf_archive' selects the listing-walk + per-PDF-extract pipeline: the URL is a paginated listing page that links to a series of dated PDF documents, each fetched and parsed (text only at V1) into one Minutes record."
2954
+ },
2955
+ bulk: {
2956
+ $ref: "#/definitions/BulkDownloadConfig"
2957
+ },
2958
+ api: {
2959
+ $ref: "#/definitions/ApiSourceConfig"
2960
+ },
2961
+ pdf: {
2962
+ $ref: "#/definitions/PdfSourceConfig"
2963
+ },
2964
+ pdfArchive: {
2965
+ $ref: "#/definitions/PdfArchiveConfig"
2966
+ },
2967
+ billDiscovery: {
2968
+ $ref: "#/definitions/BillDiscoveryConfig"
2969
+ },
2970
+ detailFields: {
2971
+ type: "object",
2972
+ additionalProperties: {
2973
+ oneOf: [
2974
+ { type: "string" },
2975
+ { $ref: "#/definitions/StructuredFieldConfig" }
2976
+ ]
2977
+ },
2978
+ description: "Detail page extraction plan: maps domain field names to CSS selectors (string) or structured array configs (object). Supports dot notation and '|attr:name' suffix."
2979
+ },
2980
+ staticManifest: {
2981
+ type: "object",
2982
+ required: ["containerSelector", "itemSelector", "fieldMappings"],
2983
+ additionalProperties: false,
2984
+ description: "Explicit extraction selectors that bypass AI structural analysis entirely. Use when the page structure is known and stable, or when the local LLM consistently generates wrong selectors.",
2985
+ properties: {
2986
+ containerSelector: { type: "string" },
2987
+ itemSelector: { type: "string" },
2988
+ fieldMappings: {
2989
+ type: "array",
2990
+ items: {
2991
+ type: "object",
2992
+ required: ["fieldName", "selector", "extractionMethod", "required"],
2993
+ additionalProperties: true,
2994
+ properties: {
2995
+ fieldName: { type: "string" },
2996
+ selector: { type: "string" },
2997
+ extractionMethod: { type: "string" },
2998
+ attribute: { type: "string" },
2999
+ regexPattern: { type: "string" },
3000
+ regexGroup: { type: "integer" },
3001
+ transform: { type: "object" },
3002
+ required: { type: "boolean" },
3003
+ defaultValue: { type: "string" },
3004
+ scope: {
3005
+ type: "string",
3006
+ enum: ["item", "container"]
3007
+ },
3008
+ children: {
3009
+ type: "object",
3010
+ additionalProperties: {
3011
+ oneOf: [
3012
+ { type: "string" },
3013
+ { $ref: "#/definitions/ChildFieldConfig" }
3014
+ ]
3015
+ },
3016
+ description: "For extractionMethod='structured': child field selectors. Each value is a string shortcut or a ChildFieldConfig object."
3017
+ }
3018
+ }
3019
+ }
3020
+ }
3021
+ }
3022
+ },
3023
+ crawlDepth: {
3024
+ type: "integer",
3025
+ minimum: 0,
3026
+ description: "Crawl depth from the seed URL for sync handlers that walk a site's link graph (currently civics \u2014 see opuspopuli#669). 0 (default) = no crawl, fetch only the seed. N = N hops from seed. Crawls are scoped to same host + same path prefix as the seed, HTML responses only."
3027
+ },
3028
+ crawlMaxPages: {
3029
+ type: "integer",
3030
+ minimum: 1,
3031
+ description: "Cap on total pages a crawler will visit per sync run regardless of crawlDepth. Defaults to 20. Each page costs one LLM call, so this bounds runtime + token spend."
3032
+ },
3033
+ llmMaxTokens: {
3034
+ type: "integer",
3035
+ minimum: 1,
3036
+ description: "Per-source override for the LLM maxTokens generation cap. Useful when a source needs a much longer or shorter output than its siblings (e.g., a 150-term glossary needs ~32000; a short bio prompt finishes well under 2000). Overrides the sync handler's hardcoded default."
3037
+ },
3038
+ llmRequestTimeoutMs: {
3039
+ type: "integer",
3040
+ minimum: 1,
3041
+ description: "Per-source override for the LLM provider request timeout (milliseconds). Useful when a single source legitimately runs much longer than the platform-wide default \u2014 civics-glossary extraction on qwen3.5:9b can take 15-20 min, while bio gen finishes in 2 min on the same hardware. Overrides the provider's constructor-configured default."
3042
+ }
3043
+ }
3044
+ },
3045
+ BulkDownloadConfig: {
3046
+ type: "object",
3047
+ required: ["format", "columnMappings"],
3048
+ additionalProperties: false,
3049
+ properties: {
3050
+ format: {
3051
+ type: "string",
3052
+ enum: ["tsv", "csv", "zip_tsv", "zip_csv"],
3053
+ description: "File format"
3054
+ },
3055
+ filePattern: {
3056
+ type: "string",
3057
+ description: "For ZIP archives: path/glob of target file(s) within the ZIP"
3058
+ },
3059
+ delimiter: {
3060
+ type: "string",
3061
+ description: "Column delimiter override"
3062
+ },
3063
+ headerLines: {
3064
+ type: "integer",
3065
+ minimum: 0,
3066
+ description: "Number of header lines to skip"
3067
+ },
3068
+ headers: {
3069
+ type: "array",
3070
+ items: { type: "string" },
3071
+ description: "Explicit column headers for files without a header row (e.g., FEC bulk files)"
3072
+ },
3073
+ columnMappings: {
3074
+ type: "object",
3075
+ additionalProperties: { type: "string" },
3076
+ description: "Column name mappings: source column name -> domain field name"
3077
+ },
3078
+ filters: {
3079
+ type: "object",
3080
+ additionalProperties: { type: "string" },
3081
+ description: `Filter expressions applied during parse. Values support \${variableName} placeholders that the consumer resolves at runtime from the active local region. Supported variables: \${stateCode} (the 2-letter US state code of the active local region, e.g. 'CA'). Placeholders must appear verbatim. Example: '"STATE": "\${stateCode}"' on an FEC bulk filter.`
3082
+ },
3083
+ batchSize: {
3084
+ type: "integer",
3085
+ minimum: 1,
3086
+ description: "Records per batch for streaming processing (default: 10000)"
3087
+ }
3088
+ }
3089
+ },
3090
+ ApiSourceConfig: {
3091
+ type: "object",
3092
+ additionalProperties: false,
3093
+ properties: {
3094
+ method: {
3095
+ type: "string",
3096
+ enum: ["GET", "POST"],
3097
+ description: "HTTP method (default: GET)"
3098
+ },
3099
+ apiKeyEnvVar: {
3100
+ type: "string",
3101
+ description: "Environment variable name containing the API key"
3102
+ },
3103
+ apiKeyHeader: {
3104
+ type: "string",
3105
+ description: "Header name for the API key"
3106
+ },
3107
+ pagination: {
3108
+ $ref: "#/definitions/ApiPaginationConfig"
3109
+ },
3110
+ resultsPath: {
3111
+ type: "string",
3112
+ description: "JSON path to the items array in the response"
3113
+ },
3114
+ queryParams: {
3115
+ type: "object",
3116
+ additionalProperties: { type: "string" },
3117
+ description: `Query parameters appended to every request. Values support \${variableName} placeholders that the consumer resolves at runtime from the active local region. Supported variables: \${stateCode} (the 2-letter US state code of the active local region, e.g. 'CA'). Placeholders must appear verbatim \u2014 no escaping, no nested expressions. Example: '"contributor_state": "\${stateCode}"'.`
3118
+ },
3119
+ fieldMappings: {
3120
+ type: "object",
3121
+ additionalProperties: { type: "string" },
3122
+ description: "Field name mappings: API response field -> domain model field (e.g., committee_id -> committeeId)"
3123
+ }
3124
+ }
3125
+ },
3126
+ ApiPaginationConfig: {
3127
+ type: "object",
3128
+ required: ["type"],
3129
+ additionalProperties: false,
3130
+ properties: {
3131
+ type: {
3132
+ type: "string",
3133
+ enum: ["offset", "cursor", "page"],
3134
+ description: "Pagination strategy type"
3135
+ },
3136
+ pageParam: {
3137
+ type: "string",
3138
+ description: "Query parameter name for page/offset"
3139
+ },
3140
+ limitParam: {
3141
+ type: "string",
3142
+ description: "Query parameter name for page size"
3143
+ },
3144
+ limit: {
3145
+ type: "integer",
3146
+ minimum: 1,
3147
+ description: "Number of items per page"
3148
+ }
3149
+ }
3150
+ },
3151
+ PdfSourceConfig: {
3152
+ type: "object",
3153
+ additionalProperties: false,
3154
+ properties: {
3155
+ skipPages: {
3156
+ type: "integer",
3157
+ minimum: 0,
3158
+ description: "Skip first N pages (e.g., skip cover page)"
3159
+ },
3160
+ maxPages: {
3161
+ type: "integer",
3162
+ minimum: 1,
3163
+ description: "Maximum pages to process (default: all)"
3164
+ },
3165
+ followPdfLink: {
3166
+ type: "boolean",
3167
+ description: "If true, treats the URL as a gateway page and follows the first PDF link"
3168
+ }
3169
+ }
3170
+ },
3171
+ StructuredFieldConfig: {
3172
+ type: "object",
3173
+ required: ["selector", "children"],
3174
+ additionalProperties: false,
3175
+ properties: {
3176
+ selector: {
3177
+ type: "string",
3178
+ description: "CSS selector matching each repeating item (e.g., '.office-card')"
3179
+ },
3180
+ children: {
3181
+ type: "object",
3182
+ additionalProperties: {
3183
+ oneOf: [
3184
+ { type: "string" },
3185
+ { $ref: "#/definitions/ChildFieldConfig" }
3186
+ ]
3187
+ },
3188
+ description: "Child field selectors relative to each item. Each entry is either a string shortcut (e.g. 'h3', 'a|attr:href') or a richer ChildFieldConfig object with extractionMethod / regex / transform."
3189
+ },
3190
+ multiple: {
3191
+ type: "boolean",
3192
+ description: "If true, extracts all matches as an array (default: true)"
3193
+ }
3194
+ }
3195
+ },
3196
+ ChildFieldConfig: {
3197
+ type: "object",
3198
+ additionalProperties: false,
3199
+ description: "Per-child extraction config for structured extraction. Object form supports the same shape as the top-level FieldMapping (selector + extractionMethod + attribute / regex / transform), minus fieldName/required/defaultValue which don't apply at child scope.",
3200
+ properties: {
3201
+ selector: {
3202
+ type: "string",
3203
+ description: "CSS selector relative to the matched parent item. Optional for regex on full element text."
3204
+ },
3205
+ extractionMethod: {
3206
+ type: "string",
3207
+ description: "How to extract: 'text', 'attribute', 'regex', 'html', etc."
3208
+ },
3209
+ attribute: {
3210
+ type: "string",
3211
+ description: "For extractionMethod='attribute': which attribute to read (e.g. 'href', 'src')."
3212
+ },
3213
+ regexPattern: {
3214
+ type: "string",
3215
+ description: "For extractionMethod='regex': the pattern."
3216
+ },
3217
+ regexGroup: {
3218
+ type: "integer",
3219
+ description: "For extractionMethod='regex': capture group (default: 1)."
3220
+ },
3221
+ transform: {
3222
+ type: "object",
3223
+ description: "Optional transform applied after extraction (e.g. trim, regex_replace, url_resolve)."
3224
+ }
3225
+ }
3226
+ },
3227
+ BillDiscoveryConfig: {
3228
+ type: "object",
3229
+ required: ["navLinkPattern", "statusPageTemplate", "votesPageTemplate"],
3230
+ additionalProperties: false,
3231
+ description: "Declarative bill discovery for legislature sites that list per-bill nav-hub links on a search results page (e.g. CA leginfo). The bills-ingest handler fetches the seed once, extracts bill IDs with navLinkPattern, and constructs status + votes URLs from the templates \u2014 bypassing generic BFS which would exhaust crawlMaxPages with nav-hub links before reaching detail pages.",
3232
+ properties: {
3233
+ navLinkPattern: {
3234
+ type: "string",
3235
+ description: 'Regex string applied to the decoded seed-page HTML to extract bill IDs. Capture group 1 must be the bill ID. Example for CA leginfo: "/faces/billNavClient\\\\.xhtml\\\\?bill_id=([^\\"&\\\\s]+)"'
3236
+ },
3237
+ statusPageTemplate: {
3238
+ type: "string",
3239
+ description: 'URL path + query template for the bill status page. Use {bill_id} as the placeholder; the service prepends the seed origin. Example: "/faces/billStatusClient.xhtml?bill_id={bill_id}"'
3240
+ },
3241
+ votesPageTemplate: {
3242
+ type: "string",
3243
+ description: 'URL path + query template for the bill votes page. Same substitution as statusPageTemplate. Example: "/faces/billVotesClient.xhtml?bill_id={bill_id}"'
3244
+ },
3245
+ textPageTemplate: {
3246
+ type: "string",
3247
+ description: `Optional URL path + query template for the bill text page. When set, the sync fetches this page before LLM extraction to check the 'Date Published' timestamp and skip unchanged bills. Example: "/faces/billTextClient.xhtml?bill_id={bill_id}"`
3248
+ }
3249
+ }
3250
+ },
3251
+ PdfArchiveConfig: {
3252
+ type: "object",
3253
+ required: ["linkSelector"],
3254
+ additionalProperties: false,
3255
+ description: "Listing-walk configuration for sourceType=pdf_archive. The page at `url` is treated as a paginated index of dated PDF documents; each linked PDF is fetched, pdf-parsed, and stored as a Minutes record. Cold-start work is bounded by maxNew (default 10) so first-run sync doesn't ingest years of historical archives. Per-document parsing (text \u2192 action records) is a downstream backend pass \u2014 this block carries no parser DSL.",
3256
+ properties: {
3257
+ linkSelector: {
3258
+ type: "string",
3259
+ description: "CSS selector for PDF anchor elements on the listing page. Each match yields a candidate document."
3260
+ },
3261
+ datePattern: {
3262
+ type: "string",
3263
+ description: "Regex applied to the link href (falling back to anchor text) to extract the document's date. Capture groups depend on dateFormat."
3264
+ },
3265
+ dateFormat: {
3266
+ type: "string",
3267
+ description: "Date format hint for interpreting datePattern's captures. Supported: 'MMDDYY' (3 captures: month, day, two-digit year \u2014 assumed 20YY), 'YYYY-MM-DD' (3 captures), 'MM/DD/YYYY' (3 captures)."
3268
+ },
3269
+ revisionPattern: {
3270
+ type: "string",
3271
+ description: "Regex against the link href whose match indicates this URL is a revision. Capture group 1 is the revision sequence number (1, 2, ...). Unmatched URLs are revisionSeq=0."
3272
+ },
3273
+ maxPages: {
3274
+ type: "integer",
3275
+ minimum: 1,
3276
+ description: "Maximum number of listing pages to walk during one sync cycle. Default: 10."
3277
+ },
3278
+ maxNew: {
3279
+ type: "integer",
3280
+ minimum: 1,
3281
+ description: "Hard cap on number of new documents ingested per sync cycle. Cold-start protection. Default: 10."
3282
+ },
3283
+ paginationParam: {
3284
+ type: "string",
3285
+ description: "Query parameter name used to paginate the listing page (e.g., 'page'). Omitted when the listing isn't paginated."
3286
+ }
3287
+ }
3288
+ }
3289
+ }
3290
+ };
3291
+
3292
+ // src/lib/region.ts
3293
+ var DATA_TYPES = [
3294
+ "propositions",
3295
+ "meetings",
3296
+ "representatives",
3297
+ "campaign_finance",
3298
+ "lobbying",
3299
+ "civics",
3300
+ "bills"
3301
+ ];
3302
+ var SOURCE_TYPES = [
3303
+ "html_scrape",
3304
+ "bulk_download",
3305
+ "api",
3306
+ "pdf",
3307
+ "pdf_archive"
3308
+ ];
3309
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3310
+ var STATE_CODE_RE = /^[A-Z]{2}$/;
3311
+ function slugify(input) {
3312
+ return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3313
+ }
3314
+ function isValidSlug(v) {
3315
+ return SLUG_RE.test(v);
3316
+ }
3317
+ function isValidStateCode(v) {
3318
+ return STATE_CODE_RE.test(v);
3319
+ }
3320
+ function isValidFips(v, level) {
3321
+ return level === "state" ? /^\d{2}$/.test(v) : /^\d{5}$/.test(v);
3322
+ }
3323
+ function supportedTimeZones() {
3324
+ const intl = Intl;
3325
+ if (typeof intl.supportedValuesOf !== "function") return null;
3326
+ try {
3327
+ return new Set(intl.supportedValuesOf("timeZone"));
3328
+ } catch {
3329
+ return null;
3330
+ }
3331
+ }
3332
+ function isValidTimezone(v) {
3333
+ if (!v.includes("/")) return false;
3334
+ const zones = supportedTimeZones();
3335
+ return zones ? zones.has(v) : true;
3336
+ }
3337
+ function buildRegionConfig(input) {
3338
+ const config = {
3339
+ // The regions repo requires `name === config.regionId`; we tie them
3340
+ // together at construction so they can never drift.
3341
+ regionId: input.regionId,
3342
+ regionName: input.regionName,
3343
+ description: input.description,
3344
+ timezone: input.timezone,
3345
+ stateCode: input.stateCode,
3346
+ fipsCode: input.fipsCode,
3347
+ dataSources: input.dataSources.map(normalizeDataSource)
3348
+ };
3349
+ if (input.parentRegionId) {
3350
+ config.parentRegionId = input.parentRegionId;
3351
+ }
3352
+ return {
3353
+ name: input.regionId,
3354
+ displayName: input.displayName,
3355
+ description: input.description,
3356
+ version: input.version,
3357
+ config
3358
+ };
3359
+ }
3360
+ function normalizeDataSource(src) {
3361
+ const out = {
3362
+ url: src.url,
3363
+ dataType: src.dataType,
3364
+ sourceType: src.sourceType,
3365
+ contentGoal: src.contentGoal
3366
+ };
3367
+ if (src.category) out.category = src.category;
3368
+ return out;
3369
+ }
3370
+ function regionFilePath(input) {
3371
+ if (input.level === "state") {
3372
+ return `regions/${input.regionId}/${input.regionId}.json`;
3373
+ }
3374
+ const parent = input.parentRegionId;
3375
+ const countySlug = input.countySlug;
3376
+ if (!parent || !countySlug) {
3377
+ throw new Error(
3378
+ "regionFilePath: county requires both parentRegionId and countySlug"
3379
+ );
3380
+ }
3381
+ return `regions/${parent}/counties/${countySlug}/${countySlug}.json`;
3382
+ }
3383
+ var schemaValidator = new Validator(region_schema_default, "7");
3384
+ function validateRegionConfig(file) {
3385
+ const issues = [];
3386
+ const { config } = file;
3387
+ if (!/^\d+\.\d+\.\d+$/.test(file.version)) {
3388
+ issues.push(`version "${file.version}" is not a valid semver (expected MAJOR.MINOR.PATCH)`);
3389
+ }
3390
+ if (file.name !== config.regionId) {
3391
+ issues.push(`name "${file.name}" must equal config.regionId "${config.regionId}"`);
3392
+ }
3393
+ if (!isValidSlug(config.regionId)) {
3394
+ issues.push(`regionId "${config.regionId}" must be kebab-case`);
3395
+ }
3396
+ if (config.parentRegionId !== void 0 && !config.regionId.startsWith(`${config.parentRegionId}-`)) {
3397
+ issues.push(
3398
+ `county regionId "${config.regionId}" should be prefixed with its parent "${config.parentRegionId}-"`
3399
+ );
3400
+ }
3401
+ const isCounty = config.parentRegionId !== void 0;
3402
+ if (config.fipsCode !== void 0) {
3403
+ const expected = isCounty ? 5 : 2;
3404
+ if (!/^\d+$/.test(config.fipsCode) || config.fipsCode.length !== expected) {
3405
+ issues.push(
3406
+ `fipsCode "${config.fipsCode}" must be ${expected} digits for a ${isCounty ? "county" : "state"}`
3407
+ );
3408
+ }
3409
+ }
3410
+ if (Array.isArray(config.dataSources)) {
3411
+ const seen = /* @__PURE__ */ new Set();
3412
+ for (const src of config.dataSources) {
3413
+ const key = `${src.dataType} ${src.url}`;
3414
+ if (seen.has(key)) {
3415
+ issues.push(`duplicate data source: ${src.dataType} ${src.url}`);
3416
+ }
3417
+ seen.add(key);
3418
+ }
3419
+ }
3420
+ const result2 = schemaValidator.validate(file);
3421
+ if (!result2.valid) {
3422
+ for (const err of result2.errors) {
3423
+ const path = err.instanceLocation || "<root>";
3424
+ issues.push(`schema: ${path} ${err.error}`);
3425
+ }
3426
+ }
3427
+ return issues;
3428
+ }
3429
+
3430
+ // src/commands/region.ts
3431
+ var regionCommand = new Command("region").description(
3432
+ "Scaffold a schema-valid region config for the OpusPopuli/opuspopuli-regions repo. Run this from inside your checkout of that repo."
3433
+ ).addOption(
3434
+ new Option("--level <level>", "Region level").choices(["state", "county"])
3435
+ ).addOption(new Option("--name <name>", 'Region name (e.g. "California" or "Alameda")')).addOption(
3436
+ new Option("--parent <slug>", "Parent state slug for a county (e.g. california)")
3437
+ ).addOption(new Option("--state-code <code>", "Two-letter US state code (e.g. CA)")).addOption(new Option("--fips <code>", "FIPS code (2 digits state, 5 digits county)")).addOption(new Option("--timezone <tz>", "IANA timezone (e.g. America/Los_Angeles)")).addOption(
3438
+ new Option("--out-dir <dir>", "Repo root to write under").default(
3439
+ process.cwd(),
3440
+ "current directory"
3441
+ )
3442
+ ).addOption(new Option("-f, --force", "Overwrite an existing config file").default(false)).action(async (opts) => {
3443
+ p3.intro(pc2.bgMagenta(pc2.black(" create-op-node region ")));
3444
+ p3.note(
3445
+ [
3446
+ "Scaffolds a declarative region config JSON for opuspopuli-regions \u2014",
3447
+ "the file that defines WHAT civic data a region collects.",
3448
+ "",
3449
+ pc2.dim("Run this from the root of your opuspopuli-regions checkout."),
3450
+ pc2.dim("It validates the same invariants the repo\u2019s `pnpm test` enforces,"),
3451
+ pc2.dim("so the file lands green and you just commit + open a PR.")
3452
+ ].join("\n"),
3453
+ "Welcome"
3454
+ );
3455
+ const targetDir = opts.outDir ?? process.cwd();
3456
+ const looksRight = await looksLikeRegionsRepo(targetDir);
3457
+ if (!looksRight) {
3458
+ const proceed = unwrap(
3459
+ await p3.confirm({
3460
+ message: `${targetDir} doesn't look like an opuspopuli-regions checkout (no schema/region-plugin.schema.json). Continue anyway?`,
3461
+ initialValue: false
3462
+ })
3463
+ );
3464
+ if (!proceed) {
3465
+ p3.cancel("Cancelled \u2014 run again from your opuspopuli-regions checkout, or pass --out-dir.");
3466
+ process.exit(0);
3467
+ }
3468
+ }
3469
+ const level = opts.level === "state" || opts.level === "county" ? opts.level : unwrap(
3470
+ await p3.select({
3471
+ message: "What level is this region?",
3472
+ options: [
3473
+ { value: "state", label: "State", hint: "e.g. California" },
3474
+ { value: "county", label: "County", hint: "e.g. Alameda, within California" }
3475
+ ]
3476
+ })
3477
+ );
3478
+ const rawName = opts.name ?? unwrap(
3479
+ await p3.text({
3480
+ message: level === "county" ? "County name?" : "State name?",
3481
+ placeholder: level === "county" ? "Alameda" : "California",
3482
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
3483
+ })
3484
+ );
3485
+ let parentSlug;
3486
+ if (level === "county") {
3487
+ parentSlug = opts.parent ?? unwrap(
3488
+ await p3.text({
3489
+ message: "Parent state slug?",
3490
+ placeholder: "california",
3491
+ validate: (v) => isValidSlug(v ?? "") ? void 0 : "kebab-case (lowercase letters, digits, hyphens)"
3492
+ })
3493
+ );
3494
+ if (!isValidSlug(parentSlug)) {
3495
+ p3.cancel(`Parent slug "${parentSlug}" must be kebab-case.`);
3496
+ process.exit(1);
3497
+ }
3498
+ }
3499
+ const ownSlug = slugify(rawName);
3500
+ const regionId = level === "county" ? `${parentSlug}-${ownSlug}` : ownSlug;
3501
+ if (!isValidSlug(regionId)) {
3502
+ p3.cancel(`Derived regionId "${regionId}" is not valid kebab-case. Try a simpler name.`);
3503
+ process.exit(1);
3504
+ }
3505
+ const displayName = unwrap(
3506
+ await p3.text({
3507
+ message: "Display name?",
3508
+ defaultValue: rawName
3509
+ })
3510
+ );
3511
+ const regionName = displayName || rawName;
3512
+ const description = unwrap(
3513
+ await p3.text({
3514
+ message: "One-line description of the data coverage?",
3515
+ placeholder: level === "county" ? `Civic data for ${rawName} County` : `Civic data for the state of ${rawName}`,
3516
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
3517
+ })
3518
+ );
3519
+ const stateCode = (opts.stateCode ?? unwrap(
3520
+ await p3.text({
3521
+ message: "Two-letter state code?",
3522
+ placeholder: "CA",
3523
+ validate: (v) => isValidStateCode((v ?? "").toUpperCase()) ? void 0 : "Two letters, e.g. CA"
3524
+ })
3525
+ )).toUpperCase();
3526
+ if (!isValidStateCode(stateCode)) {
3527
+ p3.cancel(`State code "${stateCode}" must be two letters.`);
3528
+ process.exit(1);
3529
+ }
3530
+ const fipsCode = opts.fips ?? unwrap(
3531
+ await p3.text({
3532
+ message: level === "county" ? "County FIPS (5 digits)?" : "State FIPS (2 digits)?",
3533
+ placeholder: level === "county" ? "06001" : "06",
3534
+ validate: (v) => isValidFips(v ?? "", level) ? void 0 : `Expected ${level === "county" ? "5" : "2"} digits`
3535
+ })
3536
+ );
3537
+ if (!isValidFips(fipsCode, level)) {
3538
+ p3.cancel(`FIPS "${fipsCode}" is the wrong length for a ${level}.`);
3539
+ process.exit(1);
3540
+ }
3541
+ const timezone = opts.timezone ?? unwrap(
3542
+ await p3.text({
3543
+ message: "IANA timezone?",
3544
+ defaultValue: "America/Los_Angeles",
3545
+ placeholder: "America/Los_Angeles",
3546
+ validate: (v) => isValidTimezone(v || "America/Los_Angeles") ? void 0 : "Not a recognized IANA zone (e.g. America/New_York)"
3547
+ })
3548
+ );
3549
+ const dataSources = [];
3550
+ do {
3551
+ const url = unwrap(
3552
+ await p3.text({
3553
+ message: `Data source #${dataSources.length + 1} \u2014 URL?`,
3554
+ placeholder: "https://example.gov/meetings",
3555
+ validate: (v) => /^https?:\/\//.test(v ?? "") ? void 0 : "Must start with http(s)://"
3556
+ })
3557
+ );
3558
+ const dataType = unwrap(
3559
+ await p3.select({
3560
+ message: "Data type?",
3561
+ options: DATA_TYPES.map((t) => ({ value: t, label: t }))
3562
+ })
3563
+ );
3564
+ const sourceType = unwrap(
3565
+ await p3.select({
3566
+ message: "Source type?",
3567
+ initialValue: "html_scrape",
3568
+ options: SOURCE_TYPES.map((t) => ({ value: t, label: t }))
3569
+ })
3570
+ );
3571
+ const contentGoal = unwrap(
3572
+ await p3.text({
3573
+ message: "Content goal (what should the scraper extract)?",
3574
+ placeholder: "Fetch upcoming board meeting agendas and minutes",
3575
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
3576
+ })
3577
+ );
3578
+ const category = unwrap(
3579
+ await p3.text({
3580
+ message: "Category label (optional)?",
3581
+ placeholder: "Board of Supervisors"
3582
+ })
3583
+ );
3584
+ const src = { url, dataType, sourceType, contentGoal };
3585
+ if (category && category.trim().length > 0) src.category = category.trim();
3586
+ dataSources.push(src);
3587
+ const again = unwrap(
3588
+ await p3.confirm({ message: "Add another data source?", initialValue: false })
3589
+ );
3590
+ if (!again) break;
3591
+ } while (true);
3592
+ const input = {
3593
+ regionId,
3594
+ displayName: regionName,
3595
+ regionName,
3596
+ description,
3597
+ // New region configs start at 0.1.0 — the documented convention in the
3598
+ // regions repo's CLAUDE.md. Bump to a higher version manually as the
3599
+ // config matures.
3600
+ version: "0.1.0",
3601
+ timezone,
3602
+ stateCode,
3603
+ fipsCode,
3604
+ dataSources,
3605
+ ...parentSlug ? { parentRegionId: parentSlug } : {},
3606
+ ...level === "county" ? { countySlug: ownSlug } : {}
3607
+ };
3608
+ const file = buildRegionConfig(input);
3609
+ const issues = validateRegionConfig(file);
3610
+ if (issues.length > 0) {
3611
+ p3.note(issues.map((i) => `${pc2.red("\u2022")} ${i}`).join("\n"), "Validation failed");
3612
+ p3.cancel("The generated config would not pass the regions repo CI. Re-run and adjust.");
3613
+ process.exit(1);
3614
+ }
3615
+ const relPath = regionFilePath({
3616
+ level,
3617
+ regionId,
3618
+ ...parentSlug ? { parentRegionId: parentSlug } : {},
3619
+ ...level === "county" ? { countySlug: ownSlug } : {}
3620
+ });
3621
+ const absPath = resolve(targetDir, relPath);
3622
+ const json = `${JSON.stringify(file, null, 2)}
3623
+ `;
3624
+ p3.note(json, `${relPath} (preview)`);
3625
+ if (!opts.force && await fileExists3(absPath)) {
3626
+ p3.cancel(`${relPath} already exists. Re-run with --force to overwrite.`);
3627
+ process.exit(1);
3628
+ }
3629
+ const write = unwrap(
3630
+ await p3.confirm({ message: `Write ${relPath}?`, initialValue: true })
3631
+ );
3632
+ if (!write) {
3633
+ p3.cancel("Nothing written.");
3634
+ process.exit(0);
3635
+ }
3636
+ await mkdir(dirname(absPath), { recursive: true });
3637
+ await writeFile(absPath, json, "utf8");
3638
+ p3.note(
3639
+ [
3640
+ `${pc2.green("\u2713")} Wrote ${pc2.cyan(relPath)}`,
3641
+ "",
3642
+ "Next steps in your opuspopuli-regions checkout:",
3643
+ pc2.dim(" pnpm test # schema + hierarchy validation"),
3644
+ pc2.dim(" pnpm test:connectivity # URL reachability (informational)"),
3645
+ pc2.dim(` git add ${relPath} && git commit && open a PR`)
3646
+ ].join("\n"),
3647
+ "Done"
3648
+ );
3649
+ p3.outro(pc2.magenta(`Region scaffolded: ${regionId}`));
3650
+ });
3651
+ async function fileExists3(path) {
3652
+ try {
3653
+ await access(path);
3654
+ return true;
3655
+ } catch {
3656
+ return false;
3657
+ }
3658
+ }
3659
+ async function looksLikeRegionsRepo(dir) {
3660
+ const [hasSchema, hasRegions] = await Promise.all([
3661
+ fileExists3(join(dir, "schema", "region-plugin.schema.json")),
3662
+ fileExists3(join(dir, "regions"))
3663
+ ]);
3664
+ return hasSchema && hasRegions;
3665
+ }
225
3666
 
226
3667
  // src/cli.ts
227
- var VERSION = "0.0.1";
3668
+ var VERSION = "0.2.0";
228
3669
  var program = new Command();
229
3670
  program.name("create-op-node").description(
230
3671
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
231
3672
  ).version(VERSION, "-v, --version", "show version");
232
3673
  program.addCommand(initCommand);
233
3674
  program.addCommand(bootstrapCommand);
3675
+ program.addCommand(resetCommand);
234
3676
  program.addCommand(verifyCommand);
3677
+ program.addCommand(regionCommand);
235
3678
  if (process.argv.length === 2) {
236
3679
  process.argv.push("init");
237
3680
  }
238
3681
  await program.parseAsync(process.argv).catch((err) => {
239
3682
  const msg = err instanceof Error ? err.message : String(err);
240
3683
  console.error(`
241
- ${pc.red("\u2717")} ${msg}
3684
+ ${pc2.red("\u2717")} ${msg}
242
3685
  `);
243
3686
  process.exit(1);
244
3687
  });