pinnace 0.0.0 → 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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +164 -0
  3. package/dist/car/car-build.d.ts +26 -0
  4. package/dist/car/car-build.d.ts.map +1 -0
  5. package/dist/car/car-build.js +113 -0
  6. package/dist/car/car-build.js.map +1 -0
  7. package/dist/ci/ci-emit.d.ts +116 -0
  8. package/dist/ci/ci-emit.d.ts.map +1 -0
  9. package/dist/ci/ci-emit.js +190 -0
  10. package/dist/ci/ci-emit.js.map +1 -0
  11. package/dist/cli/bin.d.ts +3 -0
  12. package/dist/cli/bin.d.ts.map +1 -0
  13. package/dist/cli/bin.js +17 -0
  14. package/dist/cli/bin.js.map +1 -0
  15. package/dist/cli/run.d.ts +94 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +731 -0
  18. package/dist/cli/run.js.map +1 -0
  19. package/dist/config/config-resolution.d.ts +186 -0
  20. package/dist/config/config-resolution.d.ts.map +1 -0
  21. package/dist/config/config-resolution.js +137 -0
  22. package/dist/config/config-resolution.js.map +1 -0
  23. package/dist/deploy/deploy.d.ts +121 -0
  24. package/dist/deploy/deploy.d.ts.map +1 -0
  25. package/dist/deploy/deploy.js +150 -0
  26. package/dist/deploy/deploy.js.map +1 -0
  27. package/dist/derive/ipns-key-derivation.d.ts +37 -0
  28. package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
  29. package/dist/derive/ipns-key-derivation.js +130 -0
  30. package/dist/derive/ipns-key-derivation.js.map +1 -0
  31. package/dist/index.d.ts +27 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +29 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/node/node-commands.d.ts +115 -0
  36. package/dist/node/node-commands.d.ts.map +1 -0
  37. package/dist/node/node-commands.js +231 -0
  38. package/dist/node/node-commands.js.map +1 -0
  39. package/dist/provision/cloud-init.d.ts +162 -0
  40. package/dist/provision/cloud-init.d.ts.map +1 -0
  41. package/dist/provision/cloud-init.js +460 -0
  42. package/dist/provision/cloud-init.js.map +1 -0
  43. package/dist/publisher/key-import.d.ts +105 -0
  44. package/dist/publisher/key-import.d.ts.map +1 -0
  45. package/dist/publisher/key-import.js +76 -0
  46. package/dist/publisher/key-import.js.map +1 -0
  47. package/dist/publisher/record-sequence.d.ts +90 -0
  48. package/dist/publisher/record-sequence.d.ts.map +1 -0
  49. package/dist/publisher/record-sequence.js +250 -0
  50. package/dist/publisher/record-sequence.js.map +1 -0
  51. package/dist/rpc/kubo-rpc-client.d.ts +154 -0
  52. package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
  53. package/dist/rpc/kubo-rpc-client.js +207 -0
  54. package/dist/rpc/kubo-rpc-client.js.map +1 -0
  55. package/dist/rpc/mock-kubo.d.ts +87 -0
  56. package/dist/rpc/mock-kubo.d.ts.map +1 -0
  57. package/dist/rpc/mock-kubo.js +129 -0
  58. package/dist/rpc/mock-kubo.js.map +1 -0
  59. package/dist/site/site-management.d.ts +126 -0
  60. package/dist/site/site-management.d.ts.map +1 -0
  61. package/dist/site/site-management.js +123 -0
  62. package/dist/site/site-management.js.map +1 -0
  63. package/dist/status/status-report.d.ts +145 -0
  64. package/dist/status/status-report.d.ts.map +1 -0
  65. package/dist/status/status-report.js +192 -0
  66. package/dist/status/status-report.js.map +1 -0
  67. package/package.json +45 -2
  68. package/src/car/car-build.ts +135 -0
  69. package/src/ci/ci-emit.ts +284 -0
  70. package/src/cli/bin.ts +20 -0
  71. package/src/cli/run.ts +970 -0
  72. package/src/config/config-resolution.ts +264 -0
  73. package/src/deploy/deploy.ts +250 -0
  74. package/src/derive/ipns-key-derivation.ts +173 -0
  75. package/src/index.ts +146 -0
  76. package/src/node/node-commands.ts +395 -0
  77. package/src/provision/cloud-init.ts +646 -0
  78. package/src/publisher/key-import.ts +141 -0
  79. package/src/publisher/record-sequence.ts +336 -0
  80. package/src/rpc/kubo-rpc-client.ts +281 -0
  81. package/src/rpc/mock-kubo.ts +194 -0
  82. package/src/site/site-management.ts +241 -0
  83. package/src/status/status-report.ts +291 -0
@@ -0,0 +1,731 @@
1
+ /**
2
+ * The CLI dispatch surface, separated from the executable shebang entry
3
+ * (bin.ts) so it is unit-testable without spawning a process. It is a THIN
4
+ * wrapper: it parses/validates args, resolves config (arg > env > file), calls
5
+ * the core, and formats the result. ALL behaviour lives in the core (CONTEXT.md
6
+ * `core vs cli`); nothing here re-implements domain logic.
7
+ *
8
+ * The client-facing verbs (provision, deploy, install-ci, status, derive) and
9
+ * the config/env layer dispatch through an injectable {@link RunContext} seam:
10
+ * - {@link RunContext.deps} are the core functions each verb calls (defaults to
11
+ * the real core; tests inject stubs to assert dispatch + resolved args),
12
+ * - {@link RunContext.env} + {@link RunContext.loadConfigFile} are the env and
13
+ * `pinnace.json` layers (defaults read the real `process.env` + file; tests
14
+ * inject in-memory values so the operator's real environment/config is never
15
+ * read or mutated — mirroring the `NodeCommandOps` injectable-ops pattern in
16
+ * node-commands and the explicit-`env` resolver in config-resolution).
17
+ *
18
+ * The on-box `pinnace node <verb>` and the `pinnace site <verb>` namespaces,
19
+ * the `promote` verb, dispatch through the SAME {@link RunContext}/
20
+ * {@link ClientDeps} seam (they are NOT a forked dispatch idiom): the on-box
21
+ * `node` verbs assemble a {@link NodeCommandContext} from the box env
22
+ * (`/etc/pinnace-node.env`, exported into `process.env` by the systemd timer's
23
+ * `EnvironmentFile`) and call the core `runNodeCommand`; `site`/`promote`
24
+ * assemble a per-host {@link KuboRpcClient} from the resolved config and call
25
+ * the site / promote core.
26
+ */
27
+ import { readFileSync } from 'node:fs';
28
+ import { name } from '../index.js';
29
+ import { NODE_VERBS, runNodeCommand as coreRunNodeCommand, } from '../node/node-commands.js';
30
+ import { SITE_VERBS, listSites as coreListSites, removeSite as coreRemoveSite, addSite as coreAddSite, } from '../site/site-management.js';
31
+ import { makeStatusOp } from '../status/status-report.js';
32
+ import { promoteReplicaToPublisher as corePromoteReplicaToPublisher, } from '../publisher/record-sequence.js';
33
+ import { deriveIpnsKey as coreDeriveIpnsKey, } from '../derive/ipns-key-derivation.js';
34
+ import { KuboRpcClient } from '../rpc/kubo-rpc-client.js';
35
+ import { provision as coreProvision, } from '../provision/cloud-init.js';
36
+ import { deploy as coreDeploy, } from '../deploy/deploy.js';
37
+ import { emitCi as coreEmitCi, } from '../ci/ci-emit.js';
38
+ import { statusReport as coreStatusReport, } from '../status/status-report.js';
39
+ import { deriveIpnsId as coreDeriveIpnsId, } from '../derive/ipns-key-derivation.js';
40
+ import { resolveConfig, resolveMasterSecret, resolveHostToken, MissingHostTokenError, } from '../config/config-resolution.js';
41
+ /** The real core, used when a caller does not inject stubs. */
42
+ const DEFAULT_DEPS = {
43
+ provision: coreProvision,
44
+ deploy: coreDeploy,
45
+ emitCi: coreEmitCi,
46
+ statusReport: coreStatusReport,
47
+ deriveIpnsId: coreDeriveIpnsId,
48
+ runNodeCommand: coreRunNodeCommand,
49
+ listSites: coreListSites,
50
+ removeSite: coreRemoveSite,
51
+ addSite: coreAddSite,
52
+ deriveIpnsKey: coreDeriveIpnsKey,
53
+ promoteReplicaToPublisher: corePromoteReplicaToPublisher,
54
+ };
55
+ /**
56
+ * Load a config file.
57
+ *
58
+ * With NO explicit path (`--config` absent), read `./pinnace.json` if present;
59
+ * its ABSENCE (or an unreadable/invalid default file) stays a benign empty
60
+ * config, because a config file is optional.
61
+ *
62
+ * With an EXPLICIT `path` (the operator typed `--config <path>`), the file MUST
63
+ * exist and parse: a missing / unreadable / invalid-JSON named path THROWS
64
+ * (naming the path) so the caller fails loud rather than silently emptying the
65
+ * config — an operator who named a file has claimed it exists.
66
+ */
67
+ function defaultLoadConfigFile(path) {
68
+ if (path === undefined) {
69
+ try {
70
+ return JSON.parse(readFileSync('pinnace.json', 'utf8'));
71
+ }
72
+ catch {
73
+ return {};
74
+ }
75
+ }
76
+ let raw;
77
+ try {
78
+ raw = readFileSync(path, 'utf8');
79
+ }
80
+ catch (cause) {
81
+ throw new ConfigLoadError(path, 'read', cause);
82
+ }
83
+ try {
84
+ return JSON.parse(raw);
85
+ }
86
+ catch (cause) {
87
+ throw new ConfigLoadError(path, 'parse', cause);
88
+ }
89
+ }
90
+ /**
91
+ * An explicitly-named `--config <path>` could not be read or parsed. Names the
92
+ * path so `run()` can emit a loud, operator-actionable error (exit 1) instead
93
+ * of silently resolving to an empty config.
94
+ */
95
+ export class ConfigLoadError extends Error {
96
+ path;
97
+ kind;
98
+ constructor(path, kind, cause) {
99
+ const detail = kind === 'read' ? 'read' : 'parse';
100
+ super(`failed to ${detail} config file '${path}'`, { cause });
101
+ this.path = path;
102
+ this.kind = kind;
103
+ this.name = 'ConfigLoadError';
104
+ }
105
+ }
106
+ /**
107
+ * Fill in the run context defaults (real env/file/core/console) once, loading
108
+ * the config from `configPath` (the operator's explicit `--config`, or
109
+ * `undefined` for the `./pinnace.json` default) through the loader seam.
110
+ */
111
+ function resolveContext(context, configPath) {
112
+ return {
113
+ env: context.env ?? process.env,
114
+ file: (context.loadConfigFile ?? defaultLoadConfigFile)(configPath),
115
+ deps: context.deps ?? DEFAULT_DEPS,
116
+ out: context.out ?? ((line) => console.log(line)),
117
+ err: context.err ?? ((line) => console.error(line)),
118
+ };
119
+ }
120
+ /**
121
+ * Dispatch a pinnace CLI invocation. Returns the process exit code.
122
+ *
123
+ * Routes the client verbs (provision/deploy/install-ci/status/derive), the
124
+ * on-box `node` namespace, and the `site` namespace. A missing command is a
125
+ * benign no-op (exit 0); an UNKNOWN command is loud (exit 1) so the surface is
126
+ * an explicit allow-list, not a silent catch-all.
127
+ *
128
+ * A GLOBAL `--config <path>` flag may appear BEFORE the command; it is consumed
129
+ * here (stripped from the per-verb argv) and threaded into config loading via
130
+ * the {@link RunContext.loadConfigFile} seam. With no `--config`, the default
131
+ * `./pinnace.json` is read and its absence is benign; an explicitly-named path
132
+ * that is missing/unreadable/invalid JSON fails loud (names the path, exit 1).
133
+ */
134
+ export async function run(argv, context = {}) {
135
+ const { configPath, rest: postGlobal } = takeConfigFlag(argv);
136
+ const err = context.err ?? ((line) => console.error(line));
137
+ let rc;
138
+ try {
139
+ rc = resolveContext(context, configPath);
140
+ }
141
+ catch (cause) {
142
+ // A loud, path-named failure only ever comes from an EXPLICIT --config;
143
+ // the default loader swallows an absent ./pinnace.json into an empty config.
144
+ if (cause instanceof ConfigLoadError) {
145
+ err(`${name()}: ${cause.message}`);
146
+ return 1;
147
+ }
148
+ throw cause;
149
+ }
150
+ const [command, ...rest] = postGlobal;
151
+ if (command === undefined) {
152
+ rc.out(`${name()}: no command given`);
153
+ return 0;
154
+ }
155
+ if (command === 'version' || command === '--version' || command === '-v') {
156
+ rc.out(name());
157
+ return 0;
158
+ }
159
+ if (command === 'node') {
160
+ return runNodeCli(rest, rc);
161
+ }
162
+ if (command === 'site') {
163
+ return runSiteCli(rest, rc);
164
+ }
165
+ if (command === 'provision') {
166
+ return runProvision(rest, rc);
167
+ }
168
+ if (command === 'deploy') {
169
+ return runDeploy(rest, rc);
170
+ }
171
+ if (command === 'install-ci') {
172
+ return runInstallCi(rest, rc);
173
+ }
174
+ if (command === 'status') {
175
+ return runStatus(rest, rc);
176
+ }
177
+ if (command === 'derive' || command === 'ipns-id') {
178
+ return runDerive(rest, rc);
179
+ }
180
+ if (command === 'promote') {
181
+ return runPromote(rest, rc);
182
+ }
183
+ rc.err(`${name()}: unknown command '${command}'`);
184
+ return 1;
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Minimal arg parsing (flags + positionals). Kept tiny + local: the CLI is a
188
+ // parse/format layer, so a full arg-parsing dependency would be over-weight.
189
+ // ---------------------------------------------------------------------------
190
+ /**
191
+ * Split the GLOBAL `--config <path>` flag off the front of the argv.
192
+ *
193
+ * `--config` is a global (not a per-verb) flag: it may precede the command and
194
+ * MUST be stripped before the argv reaches a verb parser, or a per-verb parser
195
+ * would mis-read `--config`/its path as one of its own flags/positionals. This
196
+ * scans the WHOLE argv (so `--config` before the command is found) and removes
197
+ * the flag and its value, returning the chosen path (`undefined` if absent).
198
+ * Only the last `--config` wins if repeated. A trailing `--config` with no
199
+ * value yields an empty path, which the loader then fails loud on.
200
+ */
201
+ function takeConfigFlag(argv) {
202
+ const rest = [];
203
+ let configPath;
204
+ for (let i = 0; i < argv.length; i++) {
205
+ if (argv[i] === '--config') {
206
+ const next = argv[i + 1];
207
+ configPath = next !== undefined && !next.startsWith('--') ? next : '';
208
+ if (next !== undefined && !next.startsWith('--'))
209
+ i++;
210
+ continue;
211
+ }
212
+ rest.push(argv[i]);
213
+ }
214
+ return { configPath, rest };
215
+ }
216
+ /**
217
+ * Parse `--flag value` pairs and positionals. Flags are long-form only
218
+ * (`--host hetzner`); a `--flag` at the end with no value is treated as `''`.
219
+ * Positionals are everything that is not a flag or a flag value.
220
+ */
221
+ function parseArgs(argv) {
222
+ const flags = {};
223
+ const positionals = [];
224
+ for (let i = 0; i < argv.length; i++) {
225
+ const token = argv[i];
226
+ if (token.startsWith('--')) {
227
+ const key = token.slice(2);
228
+ const next = argv[i + 1];
229
+ if (next !== undefined && !next.startsWith('--')) {
230
+ flags[key] = next;
231
+ i++;
232
+ }
233
+ else {
234
+ flags[key] = '';
235
+ }
236
+ }
237
+ else {
238
+ positionals.push(token);
239
+ }
240
+ }
241
+ return { flags, positionals };
242
+ }
243
+ // ---------------------------------------------------------------------------
244
+ // Client verbs — each: parse/validate -> resolve config -> call core -> format.
245
+ // ---------------------------------------------------------------------------
246
+ /**
247
+ * `provision --host <h> --api-domain <d> --acme-email <e> --bearer-token <t>
248
+ * --role <r> [--pinnace-version <v>] [--node-major <n>] [--kubo-version <v>]
249
+ * [...]` -> core {@link ClientDeps.provision}. Purely arg-driven (provisioning
250
+ * inputs are per-box and not stored in `pinnace.json`); prints the generated
251
+ * cloud-init to stdout. The pinned pinnace/Node/Kubo versions default to the
252
+ * generator's named knobs and are overridable per-box via these flags.
253
+ */
254
+ function runProvision(argv, rc) {
255
+ const { flags } = parseArgs(argv);
256
+ const host = flags['host'];
257
+ const apiDomain = flags['api-domain'];
258
+ const acmeEmail = flags['acme-email'];
259
+ const bearerToken = flags['bearer-token'];
260
+ const role = flags['role'];
261
+ const missing = missingFlags({
262
+ host,
263
+ 'api-domain': apiDomain,
264
+ 'acme-email': acmeEmail,
265
+ 'bearer-token': bearerToken,
266
+ role,
267
+ });
268
+ if (missing.length > 0) {
269
+ rc.err(`pinnace provision: missing required flag(s): ${missing.join(', ')}`);
270
+ return 1;
271
+ }
272
+ if (role !== 'publisher' && role !== 'replica') {
273
+ rc.err(`pinnace provision: --role must be 'publisher' or 'replica'`);
274
+ return 1;
275
+ }
276
+ const input = {
277
+ host: host,
278
+ apiDomain,
279
+ acmeEmail,
280
+ bearerToken,
281
+ role: role,
282
+ };
283
+ if (flags['dashboard-domain'])
284
+ input.dashboardDomain = flags['dashboard-domain'];
285
+ if (flags['publisher-endpoint'])
286
+ input.publisherEndpoint = flags['publisher-endpoint'];
287
+ if (flags['kubo-version'])
288
+ input.kuboVersion = flags['kubo-version'];
289
+ if (flags['pinnace-version'])
290
+ input.pinnaceVersion = flags['pinnace-version'];
291
+ if (flags['node-major'])
292
+ input.nodeMajor = flags['node-major'];
293
+ const result = rc.deps.provision(input);
294
+ rc.out(result.cloudInit.contents);
295
+ return 0;
296
+ }
297
+ /**
298
+ * `deploy [--mode <m>] <dir> <id>` -> core {@link ClientDeps.deploy}. Resolves
299
+ * every configured host into a {@link DeployTarget} (each host's OWN token
300
+ * resolved env-only, LAZILY, via {@link resolveHostToken} — CLI > env, no file),
301
+ * and the site's `mode` from the matching `pinnace.json` site entry (overridable
302
+ * with `--mode`). A host with no resolvable token FAILS LOUD naming its exact
303
+ * env var. Prints the resulting CID / per-node breakdown.
304
+ */
305
+ async function runDeploy(argv, rc) {
306
+ const { flags, positionals } = parseArgs(argv);
307
+ const [dir, siteId] = positionals;
308
+ if (!dir || !siteId) {
309
+ rc.err('pinnace deploy: usage: pinnace deploy [--mode ipfs|ipns] <dir> <id>');
310
+ return 1;
311
+ }
312
+ const cli = cliOverridesFromFlags(flags);
313
+ const cfg = resolveConfig({ file: rc.file, env: rc.env, cli });
314
+ // The site's mode: --mode arg > matching site entry (config precedence).
315
+ const siteEntry = cfg.sites.find((s) => s.id === siteId);
316
+ const mode = flags['mode'] ?? siteEntry?.mode;
317
+ if (mode !== 'ipfs' && mode !== 'ipns') {
318
+ rc.err(`pinnace deploy: mode for '${siteId}' is unset or invalid; pass --mode ipfs|ipns or add the site to pinnace.json`);
319
+ return 1;
320
+ }
321
+ if (cfg.hosts.length === 0) {
322
+ rc.err('pinnace deploy: no hosts configured (add hosts to pinnace.json)');
323
+ return 1;
324
+ }
325
+ // Resolve each host's token env-only (LAZY: only the hosts this deploy uses).
326
+ // A missing token is a loud named error, never a silent "" / downstream 401.
327
+ let targets;
328
+ try {
329
+ targets = cfg.hosts.map((h) => ({
330
+ baseUrl: h.endpoint,
331
+ token: resolveHostToken({ hostName: h.name, env: rc.env, cli }),
332
+ role: h.role,
333
+ }));
334
+ }
335
+ catch (error) {
336
+ if (error instanceof MissingHostTokenError) {
337
+ rc.err(`pinnace deploy: ${error.message}`);
338
+ return 1;
339
+ }
340
+ throw error;
341
+ }
342
+ const input = { sourceDir: dir, id: siteId, mode, targets };
343
+ const result = await rc.deps.deploy(input);
344
+ rc.out(`cid: ${result.cid}`);
345
+ for (const ok of result.ok) {
346
+ rc.out(` ok ${ok.baseUrl}${ok.published && ok.ipns ? ` (ipns ${ok.ipns})` : ''}`);
347
+ }
348
+ for (const failure of result.failed) {
349
+ rc.err(` FAIL ${failure.baseUrl}: ${failure.error.message}`);
350
+ }
351
+ return result.success ? 0 : 1;
352
+ }
353
+ /**
354
+ * `install-ci --system <s> --build-command <c> --output-dir <d> [--branch <b>]
355
+ * [--node-version <v>]` -> core {@link ClientDeps.emitCi}. Prints the workflow
356
+ * path/contents and reports the secrets/vars the operator must set.
357
+ */
358
+ function runInstallCi(argv, rc) {
359
+ const { flags } = parseArgs(argv);
360
+ const system = flags['system'];
361
+ const buildCommand = flags['build-command'];
362
+ const outputDir = flags['output-dir'];
363
+ const missing = missingFlags({
364
+ system,
365
+ 'build-command': buildCommand,
366
+ 'output-dir': outputDir,
367
+ });
368
+ if (missing.length > 0) {
369
+ rc.err(`pinnace install-ci: missing required flag(s): ${missing.join(', ')}`);
370
+ return 1;
371
+ }
372
+ const input = {
373
+ system: system,
374
+ buildCommand,
375
+ outputDir,
376
+ };
377
+ if (flags['branch'])
378
+ input.branch = flags['branch'];
379
+ if (flags['node-version'])
380
+ input.nodeVersion = flags['node-version'];
381
+ const emitted = rc.deps.emitCi(input);
382
+ rc.out(`workflow: ${emitted.workflow.path}`);
383
+ rc.out(emitted.workflow.contents);
384
+ if (emitted.secrets.length > 0) {
385
+ rc.out('Required secrets (Settings -> Secrets):');
386
+ for (const s of emitted.secrets)
387
+ rc.out(` ${s.name} — ${s.description}`);
388
+ }
389
+ if (emitted.vars.length > 0) {
390
+ rc.out('Required variables (Settings -> Variables):');
391
+ for (const v of emitted.vars)
392
+ rc.out(` ${v.name} — ${v.description}`);
393
+ }
394
+ return 0;
395
+ }
396
+ /**
397
+ * `status` -> core {@link ClientDeps.statusReport}, once per configured host
398
+ * (each node reports its OWN sites). Builds each node's Kubo client from the
399
+ * resolved endpoint + token and prints the per-site report.
400
+ */
401
+ async function runStatus(argv, rc) {
402
+ const { flags } = parseArgs(argv);
403
+ const cli = cliOverridesFromFlags(flags);
404
+ const cfg = resolveConfig({ file: rc.file, env: rc.env, cli });
405
+ if (cfg.hosts.length === 0) {
406
+ rc.err('pinnace status: no hosts configured (add hosts to pinnace.json)');
407
+ return 1;
408
+ }
409
+ for (const host of cfg.hosts) {
410
+ let token;
411
+ try {
412
+ // Env-only token, resolved LAZILY per host actually used (see deploy).
413
+ token = resolveHostToken({ hostName: host.name, env: rc.env, cli });
414
+ }
415
+ catch (error) {
416
+ if (error instanceof MissingHostTokenError) {
417
+ rc.err(`pinnace status: ${error.message}`);
418
+ return 1;
419
+ }
420
+ throw error;
421
+ }
422
+ const client = new KuboRpcClient({ baseUrl: host.endpoint, token });
423
+ const report = await rc.deps.statusReport({ client });
424
+ rc.out(`${host.name} (${host.endpoint}) peer ${report.peerId}`);
425
+ for (const site of report.sites) {
426
+ rc.out(` ${site.id}: cid ${site.cid}${site.ipns ? ` ipns ${site.ipns}` : ''} announced=${site.announced} gatewayServes=${site.gatewayServes}`);
427
+ }
428
+ }
429
+ return 0;
430
+ }
431
+ /**
432
+ * `derive <id>` (a.k.a. `ipns-id`) -> core {@link ClientDeps.deriveIpnsId}.
433
+ * Prints the site's `k51...` IPNS id from the master + the site's single `id`
434
+ * (the KDF input), with NO deploy (user story 22). The master is env-ONLY (via
435
+ * {@link resolveMasterSecret}); the `id` is either the positional argument
436
+ * verbatim or, if it names a `pinnace.json` site entry, that entry's `id` (they
437
+ * are the same value — one identifier). Fails loudly if the master is unset.
438
+ */
439
+ function runDerive(argv, rc) {
440
+ const { positionals } = parseArgs(argv);
441
+ const [siteId] = positionals;
442
+ if (!siteId) {
443
+ rc.err('pinnace derive: usage: pinnace derive <id>');
444
+ return 1;
445
+ }
446
+ const master = resolveMasterSecret({ env: rc.env });
447
+ if (!master) {
448
+ rc.err('pinnace derive: master secret not set — export PINNACE_MASTER (env-only; never read from pinnace.json)');
449
+ return 1;
450
+ }
451
+ // The single `id` IS the KDF input. The positional is the id directly; a
452
+ // matching site entry carries the same value (no separate keyId to look up).
453
+ const cfg = resolveConfig({ file: rc.file, env: rc.env, cli: {} });
454
+ const id = cfg.sites.find((s) => s.id === siteId)?.id ?? siteId;
455
+ const printed = rc.deps.deriveIpnsId({ master, keyId: id });
456
+ rc.out(printed);
457
+ return 0;
458
+ }
459
+ // ---------------------------------------------------------------------------
460
+ // Shared helpers.
461
+ // ---------------------------------------------------------------------------
462
+ /** Return the keys whose value is falsy (missing required flags), in order. */
463
+ function missingFlags(required) {
464
+ return Object.entries(required)
465
+ .filter(([, value]) => !value)
466
+ .map(([key]) => `--${key}`);
467
+ }
468
+ /**
469
+ * Build the {@link CliOverrides} the config resolver understands from parsed
470
+ * flags. `--gateways a,b` overrides the gateway list; per-host token/endpoint
471
+ * overrides use the `--host-token.<name>` / `--host-endpoint.<name>` form.
472
+ */
473
+ function cliOverridesFromFlags(flags) {
474
+ const cli = {};
475
+ const hostToken = {};
476
+ const hostEndpoint = {};
477
+ for (const [key, value] of Object.entries(flags)) {
478
+ if (key.startsWith('host-token.'))
479
+ hostToken[key.slice('host-token.'.length)] = value;
480
+ else if (key.startsWith('host-endpoint.'))
481
+ hostEndpoint[key.slice('host-endpoint.'.length)] = value;
482
+ }
483
+ if (Object.keys(hostToken).length > 0)
484
+ cli.hostToken = hostToken;
485
+ if (Object.keys(hostEndpoint).length > 0)
486
+ cli.hostEndpoint = hostEndpoint;
487
+ if (flags['gateways'])
488
+ cli.gateways = flags['gateways']
489
+ .split(',')
490
+ .map((s) => s.trim())
491
+ .filter(Boolean);
492
+ return cli;
493
+ }
494
+ /**
495
+ * `pinnace site <verb> [args] [--host <name>]` -> the site core
496
+ * ({@link ClientDeps.listSites}/{@link ClientDeps.removeSite}/
497
+ * {@link ClientDeps.addSite}). Assembles a per-host {@link KuboRpcClient} from
498
+ * the resolved config (endpoint) + the host's env-only token, then dispatches:
499
+ * - `list` -> listSites({client})
500
+ * - `remove <id>` -> removeSite({client, id})
501
+ * - `add <id> <cid>` -> addSite({client, id, cid})
502
+ * The `--host <name>` selects which configured node to act on; it may be
503
+ * omitted only when the config has exactly one host (see {@link selectHost}).
504
+ */
505
+ async function runSiteCli(argv, rc) {
506
+ const { flags, positionals } = parseArgs(argv);
507
+ const [verb, ...verbArgs] = positionals;
508
+ if (!verb) {
509
+ rc.err(`pinnace site: expected a verb (${SITE_VERBS.join(', ')})`);
510
+ return 1;
511
+ }
512
+ if (!SITE_VERBS.includes(verb)) {
513
+ rc.err(`pinnace site: unknown verb '${verb}'; expected one of ${SITE_VERBS.join(', ')}`);
514
+ return 1;
515
+ }
516
+ const cli = cliOverridesFromFlags(flags);
517
+ const cfg = resolveConfig({ file: rc.file, env: rc.env, cli });
518
+ const client = buildHostClient('pinnace site', flags['host'], cfg, rc, cli);
519
+ if (!client)
520
+ return 1; // buildHostClient already emitted the loud error.
521
+ if (verb === 'list') {
522
+ const sites = await rc.deps.listSites({ client });
523
+ for (const site of sites) {
524
+ rc.out(`${site.id}: cid ${site.cid}${site.ipns ? ` ipns ${site.ipns}` : ''}`);
525
+ }
526
+ return 0;
527
+ }
528
+ if (verb === 'remove') {
529
+ const [id] = verbArgs;
530
+ if (!id) {
531
+ rc.err('pinnace site remove: usage: pinnace site remove <id> [--host <name>]');
532
+ return 1;
533
+ }
534
+ const result = await rc.deps.removeSite({ client, id });
535
+ rc.out(`removed ${result.id}${result.cid ? ` (cid ${result.cid}, unpinned=${result.unpinned})` : ''}`);
536
+ return 0;
537
+ }
538
+ // verb === 'add'
539
+ const [id, cid] = verbArgs;
540
+ if (!id || !cid) {
541
+ rc.err('pinnace site add: usage: pinnace site add <id> <cid> [--host <name>]');
542
+ return 1;
543
+ }
544
+ const result = await rc.deps.addSite({ client, id, cid });
545
+ rc.out(`added ${result.id} -> ${result.cid}`);
546
+ return 0;
547
+ }
548
+ /**
549
+ * `pinnace node <verb>` -> the on-box command core ({@link
550
+ * ClientDeps.runNodeCommand}). This is the load-bearing on-box wiring: the verb
551
+ * runs ON a provisioned box (invoked by the systemd timer, whose
552
+ * `EnvironmentFile=/etc/pinnace-node.env` exports the box config into the
553
+ * environment {@link RunContext.env} reads). It assembles a
554
+ * {@link NodeCommandContext} from that env — a LOCAL Kubo client (127.0.0.1:5001
555
+ * + `RPC_BEARER_TOKEN`), the box `role` (`NODE_ROLE`), and the on-box paths
556
+ * (`RECORDS_DIR`/`CACHE_DIR`/`DASHBOARD_DIR`/`SITES_DIR`), the replica
557
+ * `PUBLISHER_ENDPOINT`, and the `WARM_GATEWAYS` list — and injects the OWNED
558
+ * `status` op ({@link makeStatusOp}, the real per-site announce/gateway report)
559
+ * so the production status path is not the thin default stub. It then invokes
560
+ * the core (NOT a validate-and-return-0). A live run proved that without this
561
+ * the `republish`/`mirror` timers are no-ops. The token is env-only and its
562
+ * absence is a LOUD, named error (never a silent empty bearer / 401).
563
+ */
564
+ async function runNodeCli(argv, rc) {
565
+ const [verb] = argv;
566
+ if (!verb) {
567
+ rc.err(`pinnace node: expected a verb (${NODE_VERBS.join(', ')})`);
568
+ return 1;
569
+ }
570
+ if (!NODE_VERBS.includes(verb)) {
571
+ rc.err(`pinnace node: unknown verb '${verb}'; expected one of ${NODE_VERBS.join(', ')}`);
572
+ return 1;
573
+ }
574
+ const env = rc.env;
575
+ const token = env['RPC_BEARER_TOKEN'];
576
+ if (!token) {
577
+ rc.err('pinnace node: RPC_BEARER_TOKEN not set — the on-box bearer is env-only ' +
578
+ '(read from /etc/pinnace-node.env); never a silent empty token');
579
+ return 1;
580
+ }
581
+ const role = env['NODE_ROLE'];
582
+ if (role !== 'publisher' && role !== 'replica') {
583
+ rc.err("pinnace node: NODE_ROLE must be 'publisher' or 'replica' " +
584
+ '(read from /etc/pinnace-node.env)');
585
+ return 1;
586
+ }
587
+ // The box's OWN daemon: the local Kubo RPC on 127.0.0.1:5001 (Caddy fronts it
588
+ // for external clients, but the on-box agent speaks to it directly).
589
+ const client = new KuboRpcClient({
590
+ baseUrl: 'http://127.0.0.1:5001',
591
+ token,
592
+ });
593
+ const ctx = {
594
+ client,
595
+ role,
596
+ // The production status path uses the OWNED status op (real announce +
597
+ // gateway report), not the thin defaultStatus stand-in.
598
+ ops: { status: makeStatusOp() },
599
+ };
600
+ if (env['SITES_DIR'])
601
+ ctx.sitesDir = env['SITES_DIR'];
602
+ if (env['RECORDS_DIR'])
603
+ ctx.recordsDir = env['RECORDS_DIR'];
604
+ if (env['CACHE_DIR'])
605
+ ctx.cacheDir = env['CACHE_DIR'];
606
+ if (env['DASHBOARD_DIR'])
607
+ ctx.dashboardDir = env['DASHBOARD_DIR'];
608
+ if (env['PUBLISHER_ENDPOINT'])
609
+ ctx.publisherEndpoint = env['PUBLISHER_ENDPOINT'];
610
+ const gateways = splitWarmGateways(env['WARM_GATEWAYS']);
611
+ if (gateways.length > 0)
612
+ ctx.gateways = gateways;
613
+ const result = await rc.deps.runNodeCommand(verb, ctx);
614
+ if (result.skipped) {
615
+ rc.out(`pinnace node ${verb}: skipped (${result.skippedReason})`);
616
+ return 0;
617
+ }
618
+ for (const site of result.sites) {
619
+ rc.out(` ${site.id}${site.cid ? ` cid ${site.cid}` : ''}${site.status ? ` (${site.status})` : ''}`);
620
+ }
621
+ return 0;
622
+ }
623
+ /**
624
+ * `pinnace promote <id> [--host <name>]` -> {@link
625
+ * ClientDeps.promoteReplicaToPublisher} (spec user story 14). Derives the
626
+ * per-site key from the env-only master + the site `id` (the KDF input,
627
+ * {@link ClientDeps.deriveIpnsKey}), assembles the chosen host's client, and
628
+ * promotes it: import the key + flip the role to publisher, recovering the name
629
+ * within the record's validity window without content downtime. The master is
630
+ * env-ONLY (never from `pinnace.json`); its absence is a LOUD error.
631
+ */
632
+ async function runPromote(argv, rc) {
633
+ const { flags, positionals } = parseArgs(argv);
634
+ const [siteId] = positionals;
635
+ if (!siteId) {
636
+ rc.err('pinnace promote: usage: pinnace promote <id> [--host <name>]');
637
+ return 1;
638
+ }
639
+ const master = resolveMasterSecret({ env: rc.env });
640
+ if (!master) {
641
+ rc.err('pinnace promote: master secret not set — export PINNACE_MASTER (env-only; never read from pinnace.json)');
642
+ return 1;
643
+ }
644
+ const cli = cliOverridesFromFlags(flags);
645
+ const cfg = resolveConfig({ file: rc.file, env: rc.env, cli });
646
+ const host = pickHost('pinnace promote', flags['host'], cfg, rc);
647
+ if (!host)
648
+ return 1;
649
+ const client = clientForHost('pinnace promote', host, rc, cli);
650
+ if (!client)
651
+ return 1;
652
+ // The single `id` IS the KDF input (no separate keyId), matching `derive`.
653
+ const id = cfg.sites.find((s) => s.id === siteId)?.id ?? siteId;
654
+ const derived = rc.deps.deriveIpnsKey({ master, keyId: id });
655
+ const result = await rc.deps.promoteReplicaToPublisher({
656
+ client,
657
+ currentRole: host.role,
658
+ keyName: id,
659
+ derived,
660
+ });
661
+ rc.out(`promoted ${result.keyName} to ${result.role}${result.ipns ? ` (ipns ${result.ipns})` : ''}`);
662
+ return 0;
663
+ }
664
+ // ---------------------------------------------------------------------------
665
+ // Host selection + client assembly (shared by site + promote).
666
+ // ---------------------------------------------------------------------------
667
+ /**
668
+ * Pick the configured host to act on. `--host <name>` selects it by name; it
669
+ * may be omitted ONLY when the config has exactly one host (the unambiguous
670
+ * case). Zero hosts, an unknown name, or an omitted name with several hosts is
671
+ * a LOUD, specific error (returns undefined after emitting it).
672
+ */
673
+ function pickHost(prefix, hostName, cfg, rc) {
674
+ if (cfg.hosts.length === 0) {
675
+ rc.err(`${prefix}: no hosts configured (add hosts to pinnace.json)`);
676
+ return undefined;
677
+ }
678
+ if (hostName) {
679
+ const match = cfg.hosts.find((h) => h.name === hostName);
680
+ if (!match) {
681
+ rc.err(`${prefix}: unknown host '${hostName}'; configured hosts: ${cfg.hosts
682
+ .map((h) => h.name)
683
+ .join(', ')}`);
684
+ return undefined;
685
+ }
686
+ return match;
687
+ }
688
+ if (cfg.hosts.length > 1) {
689
+ rc.err(`${prefix}: multiple hosts configured; pass --host <name> (one of ${cfg.hosts
690
+ .map((h) => h.name)
691
+ .join(', ')})`);
692
+ return undefined;
693
+ }
694
+ return cfg.hosts[0];
695
+ }
696
+ /**
697
+ * Build a {@link KuboRpcClient} for a chosen host, resolving its bearer token
698
+ * env-only (CLI > env, no file). A missing token is a LOUD, named error
699
+ * (returns undefined after emitting it).
700
+ */
701
+ function clientForHost(prefix, host, rc, cli) {
702
+ let token;
703
+ try {
704
+ token = resolveHostToken({ hostName: host.name, env: rc.env, cli });
705
+ }
706
+ catch (error) {
707
+ if (error instanceof MissingHostTokenError) {
708
+ rc.err(`${prefix}: ${error.message}`);
709
+ return undefined;
710
+ }
711
+ throw error;
712
+ }
713
+ return new KuboRpcClient({ baseUrl: host.endpoint, token });
714
+ }
715
+ /** Pick a host AND build its client in one step (the common site path). */
716
+ function buildHostClient(prefix, hostName, cfg, rc, cli) {
717
+ const host = pickHost(prefix, hostName, cfg, rc);
718
+ if (!host)
719
+ return undefined;
720
+ return clientForHost(prefix, host, rc, cli);
721
+ }
722
+ /** Split the space-separated `WARM_GATEWAYS` env value into a template list. */
723
+ function splitWarmGateways(value) {
724
+ if (!value)
725
+ return [];
726
+ return value
727
+ .split(/\s+/)
728
+ .map((s) => s.trim())
729
+ .filter(Boolean);
730
+ }
731
+ //# sourceMappingURL=run.js.map