pinnace 0.0.0 → 0.1.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/LICENSE +661 -0
- package/README.md +15 -0
- package/dist/car/car-build.d.ts +26 -0
- package/dist/car/car-build.d.ts.map +1 -0
- package/dist/car/car-build.js +113 -0
- package/dist/car/car-build.js.map +1 -0
- package/dist/ci/ci-emit.d.ts +116 -0
- package/dist/ci/ci-emit.d.ts.map +1 -0
- package/dist/ci/ci-emit.js +190 -0
- package/dist/ci/ci-emit.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +17 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/run.d.ts +78 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +506 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/config/config-resolution.d.ts +186 -0
- package/dist/config/config-resolution.d.ts.map +1 -0
- package/dist/config/config-resolution.js +137 -0
- package/dist/config/config-resolution.js.map +1 -0
- package/dist/deploy/deploy.d.ts +121 -0
- package/dist/deploy/deploy.d.ts.map +1 -0
- package/dist/deploy/deploy.js +150 -0
- package/dist/deploy/deploy.js.map +1 -0
- package/dist/derive/ipns-key-derivation.d.ts +37 -0
- package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
- package/dist/derive/ipns-key-derivation.js +130 -0
- package/dist/derive/ipns-key-derivation.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/node/node-commands.d.ts +115 -0
- package/dist/node/node-commands.d.ts.map +1 -0
- package/dist/node/node-commands.js +227 -0
- package/dist/node/node-commands.js.map +1 -0
- package/dist/provision/cloud-init.d.ts +137 -0
- package/dist/provision/cloud-init.d.ts.map +1 -0
- package/dist/provision/cloud-init.js +390 -0
- package/dist/provision/cloud-init.js.map +1 -0
- package/dist/publisher/key-import.d.ts +105 -0
- package/dist/publisher/key-import.d.ts.map +1 -0
- package/dist/publisher/key-import.js +76 -0
- package/dist/publisher/key-import.js.map +1 -0
- package/dist/publisher/record-sequence.d.ts +90 -0
- package/dist/publisher/record-sequence.d.ts.map +1 -0
- package/dist/publisher/record-sequence.js +250 -0
- package/dist/publisher/record-sequence.js.map +1 -0
- package/dist/rpc/kubo-rpc-client.d.ts +154 -0
- package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
- package/dist/rpc/kubo-rpc-client.js +207 -0
- package/dist/rpc/kubo-rpc-client.js.map +1 -0
- package/dist/rpc/mock-kubo.d.ts +87 -0
- package/dist/rpc/mock-kubo.d.ts.map +1 -0
- package/dist/rpc/mock-kubo.js +129 -0
- package/dist/rpc/mock-kubo.js.map +1 -0
- package/dist/site/site-management.d.ts +126 -0
- package/dist/site/site-management.d.ts.map +1 -0
- package/dist/site/site-management.js +123 -0
- package/dist/site/site-management.js.map +1 -0
- package/dist/status/status-report.d.ts +145 -0
- package/dist/status/status-report.d.ts.map +1 -0
- package/dist/status/status-report.js +192 -0
- package/dist/status/status-report.js.map +1 -0
- package/package.json +45 -2
- package/src/car/car-build.ts +135 -0
- package/src/ci/ci-emit.ts +284 -0
- package/src/cli/bin.ts +20 -0
- package/src/cli/run.ts +654 -0
- package/src/config/config-resolution.ts +264 -0
- package/src/deploy/deploy.ts +250 -0
- package/src/derive/ipns-key-derivation.ts +173 -0
- package/src/index.ts +146 -0
- package/src/node/node-commands.ts +391 -0
- package/src/provision/cloud-init.ts +551 -0
- package/src/publisher/key-import.ts +141 -0
- package/src/publisher/record-sequence.ts +336 -0
- package/src/rpc/kubo-rpc-client.ts +281 -0
- package/src/rpc/mock-kubo.ts +194 -0
- package/src/site/site-management.ts +241 -0
- package/src/status/status-report.ts +291 -0
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
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 are
|
|
19
|
+
* validated here for surface coherence (one CLI), but their full dispatch is
|
|
20
|
+
* wired by their own tasks (node-agent-commands, site-management).
|
|
21
|
+
*/
|
|
22
|
+
import {readFileSync} from 'node:fs';
|
|
23
|
+
import {name} from '../index.js';
|
|
24
|
+
import {NODE_VERBS, type NodeVerb} from '../node/node-commands.js';
|
|
25
|
+
import {SITE_VERBS, type SiteVerb} from '../site/site-management.js';
|
|
26
|
+
import {KuboRpcClient} from '../rpc/kubo-rpc-client.js';
|
|
27
|
+
import {
|
|
28
|
+
provision as coreProvision,
|
|
29
|
+
type ProvisionInput,
|
|
30
|
+
type ProvisionResult,
|
|
31
|
+
type HostName,
|
|
32
|
+
} from '../provision/cloud-init.js';
|
|
33
|
+
import {
|
|
34
|
+
deploy as coreDeploy,
|
|
35
|
+
type DeployInput,
|
|
36
|
+
type DeployResult,
|
|
37
|
+
type DeployTarget,
|
|
38
|
+
} from '../deploy/deploy.js';
|
|
39
|
+
import {
|
|
40
|
+
emitCi as coreEmitCi,
|
|
41
|
+
type EmitCiInput,
|
|
42
|
+
type EmittedCi,
|
|
43
|
+
type CiSystem,
|
|
44
|
+
} from '../ci/ci-emit.js';
|
|
45
|
+
import {
|
|
46
|
+
statusReport as coreStatusReport,
|
|
47
|
+
type StatusReportInput,
|
|
48
|
+
type StatusReport,
|
|
49
|
+
} from '../status/status-report.js';
|
|
50
|
+
import {
|
|
51
|
+
deriveIpnsId as coreDeriveIpnsId,
|
|
52
|
+
type DeriveIpnsInput,
|
|
53
|
+
} from '../derive/ipns-key-derivation.js';
|
|
54
|
+
import {
|
|
55
|
+
resolveConfig,
|
|
56
|
+
resolveMasterSecret,
|
|
57
|
+
resolveHostToken,
|
|
58
|
+
MissingHostTokenError,
|
|
59
|
+
type PinnaceConfigFile,
|
|
60
|
+
type EnvRecord,
|
|
61
|
+
type CliOverrides,
|
|
62
|
+
type SiteMode,
|
|
63
|
+
type HostRole,
|
|
64
|
+
} from '../config/config-resolution.js';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The core functions the client verbs dispatch to. This seam is what makes the
|
|
68
|
+
* CLI a THIN wrapper AND independently testable: production wires the real core
|
|
69
|
+
* ({@link DEFAULT_DEPS}); tests inject recording stubs and assert each verb
|
|
70
|
+
* calls the RIGHT function with the correctly-resolved arguments (rather than
|
|
71
|
+
* re-testing the core through the CLI).
|
|
72
|
+
*/
|
|
73
|
+
export interface ClientDeps {
|
|
74
|
+
/** `provision` -> the cloud-init generator. */
|
|
75
|
+
provision(input: ProvisionInput): ProvisionResult;
|
|
76
|
+
/** `deploy` -> the multi-target CAR deploy. */
|
|
77
|
+
deploy(input: DeployInput): Promise<DeployResult>;
|
|
78
|
+
/** `install-ci` -> the CI workflow emitter. */
|
|
79
|
+
emitCi(input: EmitCiInput): EmittedCi;
|
|
80
|
+
/** `status` -> the per-site status report. */
|
|
81
|
+
statusReport(input: StatusReportInput): Promise<StatusReport>;
|
|
82
|
+
/** `derive` -> the master + site `id` -> IPNS id derivation (no deploy). */
|
|
83
|
+
deriveIpnsId(input: DeriveIpnsInput): string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The real core, used when a caller does not inject stubs. */
|
|
87
|
+
const DEFAULT_DEPS: ClientDeps = {
|
|
88
|
+
provision: coreProvision,
|
|
89
|
+
deploy: coreDeploy,
|
|
90
|
+
emitCi: coreEmitCi,
|
|
91
|
+
statusReport: coreStatusReport,
|
|
92
|
+
deriveIpnsId: coreDeriveIpnsId,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The CLI run context: the injectable env/config/output/core seams. Everything
|
|
97
|
+
* is optional; omitted fields default to the real process environment, a real
|
|
98
|
+
* `pinnace.json` read, `console` sinks, and the real core. Tests pass explicit
|
|
99
|
+
* in-memory values to stay hermetic.
|
|
100
|
+
*/
|
|
101
|
+
export interface RunContext {
|
|
102
|
+
/** The env layer (defaults to `process.env`). Tests pass an in-memory record. */
|
|
103
|
+
env?: EnvRecord;
|
|
104
|
+
/**
|
|
105
|
+
* Load the parsed config file (defaults to reading `./pinnace.json`, or an
|
|
106
|
+
* empty config if absent). Tests pass an in-memory object.
|
|
107
|
+
*
|
|
108
|
+
* `path` is the operator's explicit `--config <path>` when given, else
|
|
109
|
+
* `undefined` (the `./pinnace.json` default). The default loader treats an
|
|
110
|
+
* ABSENT default file as a benign empty config, but an explicitly-named path
|
|
111
|
+
* that is missing / unreadable / invalid JSON must THROW so `run()` can fail
|
|
112
|
+
* loud naming that path (an operator-named file is a claim it exists).
|
|
113
|
+
*/
|
|
114
|
+
loadConfigFile?: (path?: string) => PinnaceConfigFile;
|
|
115
|
+
/** The core functions to dispatch to (defaults to the real core). */
|
|
116
|
+
deps?: ClientDeps;
|
|
117
|
+
/** stdout sink (defaults to `console.log`). */
|
|
118
|
+
out?: (line: string) => void;
|
|
119
|
+
/** stderr sink (defaults to `console.error`). */
|
|
120
|
+
err?: (line: string) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A resolved run context: every seam filled in with its default if omitted. */
|
|
124
|
+
interface ResolvedRunContext {
|
|
125
|
+
env: EnvRecord;
|
|
126
|
+
file: PinnaceConfigFile;
|
|
127
|
+
deps: ClientDeps;
|
|
128
|
+
out: (line: string) => void;
|
|
129
|
+
err: (line: string) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Load a config file.
|
|
134
|
+
*
|
|
135
|
+
* With NO explicit path (`--config` absent), read `./pinnace.json` if present;
|
|
136
|
+
* its ABSENCE (or an unreadable/invalid default file) stays a benign empty
|
|
137
|
+
* config, because a config file is optional.
|
|
138
|
+
*
|
|
139
|
+
* With an EXPLICIT `path` (the operator typed `--config <path>`), the file MUST
|
|
140
|
+
* exist and parse: a missing / unreadable / invalid-JSON named path THROWS
|
|
141
|
+
* (naming the path) so the caller fails loud rather than silently emptying the
|
|
142
|
+
* config — an operator who named a file has claimed it exists.
|
|
143
|
+
*/
|
|
144
|
+
function defaultLoadConfigFile(path?: string): PinnaceConfigFile {
|
|
145
|
+
if (path === undefined) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(
|
|
148
|
+
readFileSync('pinnace.json', 'utf8'),
|
|
149
|
+
) as PinnaceConfigFile;
|
|
150
|
+
} catch {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
let raw: string;
|
|
155
|
+
try {
|
|
156
|
+
raw = readFileSync(path, 'utf8');
|
|
157
|
+
} catch (cause) {
|
|
158
|
+
throw new ConfigLoadError(path, 'read', cause);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(raw) as PinnaceConfigFile;
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
throw new ConfigLoadError(path, 'parse', cause);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* An explicitly-named `--config <path>` could not be read or parsed. Names the
|
|
169
|
+
* path so `run()` can emit a loud, operator-actionable error (exit 1) instead
|
|
170
|
+
* of silently resolving to an empty config.
|
|
171
|
+
*/
|
|
172
|
+
export class ConfigLoadError extends Error {
|
|
173
|
+
constructor(
|
|
174
|
+
readonly path: string,
|
|
175
|
+
readonly kind: 'read' | 'parse',
|
|
176
|
+
cause?: unknown,
|
|
177
|
+
) {
|
|
178
|
+
const detail = kind === 'read' ? 'read' : 'parse';
|
|
179
|
+
super(`failed to ${detail} config file '${path}'`, {cause});
|
|
180
|
+
this.name = 'ConfigLoadError';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fill in the run context defaults (real env/file/core/console) once, loading
|
|
186
|
+
* the config from `configPath` (the operator's explicit `--config`, or
|
|
187
|
+
* `undefined` for the `./pinnace.json` default) through the loader seam.
|
|
188
|
+
*/
|
|
189
|
+
function resolveContext(
|
|
190
|
+
context: RunContext,
|
|
191
|
+
configPath?: string,
|
|
192
|
+
): ResolvedRunContext {
|
|
193
|
+
return {
|
|
194
|
+
env: context.env ?? (process.env as EnvRecord),
|
|
195
|
+
file: (context.loadConfigFile ?? defaultLoadConfigFile)(configPath),
|
|
196
|
+
deps: context.deps ?? DEFAULT_DEPS,
|
|
197
|
+
out: context.out ?? ((line) => console.log(line)),
|
|
198
|
+
err: context.err ?? ((line) => console.error(line)),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Dispatch a pinnace CLI invocation. Returns the process exit code.
|
|
204
|
+
*
|
|
205
|
+
* Routes the client verbs (provision/deploy/install-ci/status/derive), the
|
|
206
|
+
* on-box `node` namespace, and the `site` namespace. A missing command is a
|
|
207
|
+
* benign no-op (exit 0); an UNKNOWN command is loud (exit 1) so the surface is
|
|
208
|
+
* an explicit allow-list, not a silent catch-all.
|
|
209
|
+
*
|
|
210
|
+
* A GLOBAL `--config <path>` flag may appear BEFORE the command; it is consumed
|
|
211
|
+
* here (stripped from the per-verb argv) and threaded into config loading via
|
|
212
|
+
* the {@link RunContext.loadConfigFile} seam. With no `--config`, the default
|
|
213
|
+
* `./pinnace.json` is read and its absence is benign; an explicitly-named path
|
|
214
|
+
* that is missing/unreadable/invalid JSON fails loud (names the path, exit 1).
|
|
215
|
+
*/
|
|
216
|
+
export async function run(
|
|
217
|
+
argv: readonly string[],
|
|
218
|
+
context: RunContext = {},
|
|
219
|
+
): Promise<number> {
|
|
220
|
+
const {configPath, rest: postGlobal} = takeConfigFlag(argv);
|
|
221
|
+
|
|
222
|
+
const err = context.err ?? ((line) => console.error(line));
|
|
223
|
+
let rc: ResolvedRunContext;
|
|
224
|
+
try {
|
|
225
|
+
rc = resolveContext(context, configPath);
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
// A loud, path-named failure only ever comes from an EXPLICIT --config;
|
|
228
|
+
// the default loader swallows an absent ./pinnace.json into an empty config.
|
|
229
|
+
if (cause instanceof ConfigLoadError) {
|
|
230
|
+
err(`${name()}: ${cause.message}`);
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
throw cause;
|
|
234
|
+
}
|
|
235
|
+
const [command, ...rest] = postGlobal;
|
|
236
|
+
|
|
237
|
+
if (command === undefined) {
|
|
238
|
+
rc.out(`${name()}: no command given`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
242
|
+
rc.out(name());
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
if (command === 'node') {
|
|
246
|
+
return runNodeCli(rest, rc);
|
|
247
|
+
}
|
|
248
|
+
if (command === 'site') {
|
|
249
|
+
return runSiteCli(rest, rc);
|
|
250
|
+
}
|
|
251
|
+
if (command === 'provision') {
|
|
252
|
+
return runProvision(rest, rc);
|
|
253
|
+
}
|
|
254
|
+
if (command === 'deploy') {
|
|
255
|
+
return runDeploy(rest, rc);
|
|
256
|
+
}
|
|
257
|
+
if (command === 'install-ci') {
|
|
258
|
+
return runInstallCi(rest, rc);
|
|
259
|
+
}
|
|
260
|
+
if (command === 'status') {
|
|
261
|
+
return runStatus(rest, rc);
|
|
262
|
+
}
|
|
263
|
+
if (command === 'derive' || command === 'ipns-id') {
|
|
264
|
+
return runDerive(rest, rc);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
rc.err(`${name()}: unknown command '${command}'`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Minimal arg parsing (flags + positionals). Kept tiny + local: the CLI is a
|
|
273
|
+
// parse/format layer, so a full arg-parsing dependency would be over-weight.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Split the GLOBAL `--config <path>` flag off the front of the argv.
|
|
278
|
+
*
|
|
279
|
+
* `--config` is a global (not a per-verb) flag: it may precede the command and
|
|
280
|
+
* MUST be stripped before the argv reaches a verb parser, or a per-verb parser
|
|
281
|
+
* would mis-read `--config`/its path as one of its own flags/positionals. This
|
|
282
|
+
* scans the WHOLE argv (so `--config` before the command is found) and removes
|
|
283
|
+
* the flag and its value, returning the chosen path (`undefined` if absent).
|
|
284
|
+
* Only the last `--config` wins if repeated. A trailing `--config` with no
|
|
285
|
+
* value yields an empty path, which the loader then fails loud on.
|
|
286
|
+
*/
|
|
287
|
+
function takeConfigFlag(argv: readonly string[]): {
|
|
288
|
+
configPath?: string;
|
|
289
|
+
rest: string[];
|
|
290
|
+
} {
|
|
291
|
+
const rest: string[] = [];
|
|
292
|
+
let configPath: string | undefined;
|
|
293
|
+
for (let i = 0; i < argv.length; i++) {
|
|
294
|
+
if (argv[i] === '--config') {
|
|
295
|
+
const next = argv[i + 1];
|
|
296
|
+
configPath = next !== undefined && !next.startsWith('--') ? next : '';
|
|
297
|
+
if (next !== undefined && !next.startsWith('--')) i++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
rest.push(argv[i]);
|
|
301
|
+
}
|
|
302
|
+
return {configPath, rest};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A parsed argv split into `--flag value` map + bare positionals. */
|
|
306
|
+
interface ParsedArgs {
|
|
307
|
+
flags: Record<string, string>;
|
|
308
|
+
positionals: string[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Parse `--flag value` pairs and positionals. Flags are long-form only
|
|
313
|
+
* (`--host hetzner`); a `--flag` at the end with no value is treated as `''`.
|
|
314
|
+
* Positionals are everything that is not a flag or a flag value.
|
|
315
|
+
*/
|
|
316
|
+
function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
317
|
+
const flags: Record<string, string> = {};
|
|
318
|
+
const positionals: string[] = [];
|
|
319
|
+
for (let i = 0; i < argv.length; i++) {
|
|
320
|
+
const token = argv[i];
|
|
321
|
+
if (token.startsWith('--')) {
|
|
322
|
+
const key = token.slice(2);
|
|
323
|
+
const next = argv[i + 1];
|
|
324
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
325
|
+
flags[key] = next;
|
|
326
|
+
i++;
|
|
327
|
+
} else {
|
|
328
|
+
flags[key] = '';
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
positionals.push(token);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return {flags, positionals};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Client verbs — each: parse/validate -> resolve config -> call core -> format.
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `provision --host <h> --api-domain <d> --acme-email <e> --bearer-token <t>
|
|
343
|
+
* --role <r> [...]` -> core {@link ClientDeps.provision}. Purely arg-driven
|
|
344
|
+
* (provisioning inputs are per-box and not stored in `pinnace.json`); prints the
|
|
345
|
+
* generated cloud-init to stdout.
|
|
346
|
+
*/
|
|
347
|
+
function runProvision(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
348
|
+
const {flags} = parseArgs(argv);
|
|
349
|
+
const host = flags['host'];
|
|
350
|
+
const apiDomain = flags['api-domain'];
|
|
351
|
+
const acmeEmail = flags['acme-email'];
|
|
352
|
+
const bearerToken = flags['bearer-token'];
|
|
353
|
+
const role = flags['role'];
|
|
354
|
+
const missing = missingFlags({
|
|
355
|
+
host,
|
|
356
|
+
'api-domain': apiDomain,
|
|
357
|
+
'acme-email': acmeEmail,
|
|
358
|
+
'bearer-token': bearerToken,
|
|
359
|
+
role,
|
|
360
|
+
});
|
|
361
|
+
if (missing.length > 0) {
|
|
362
|
+
rc.err(
|
|
363
|
+
`pinnace provision: missing required flag(s): ${missing.join(', ')}`,
|
|
364
|
+
);
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
if (role !== 'publisher' && role !== 'replica') {
|
|
368
|
+
rc.err(`pinnace provision: --role must be 'publisher' or 'replica'`);
|
|
369
|
+
return 1;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const input: ProvisionInput = {
|
|
373
|
+
host: host as HostName,
|
|
374
|
+
apiDomain,
|
|
375
|
+
acmeEmail,
|
|
376
|
+
bearerToken,
|
|
377
|
+
role: role as HostRole,
|
|
378
|
+
};
|
|
379
|
+
if (flags['dashboard-domain'])
|
|
380
|
+
input.dashboardDomain = flags['dashboard-domain'];
|
|
381
|
+
if (flags['publisher-endpoint'])
|
|
382
|
+
input.publisherEndpoint = flags['publisher-endpoint'];
|
|
383
|
+
|
|
384
|
+
const result = rc.deps.provision(input);
|
|
385
|
+
rc.out(result.cloudInit.contents);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* `deploy [--mode <m>] <dir> <id>` -> core {@link ClientDeps.deploy}. Resolves
|
|
391
|
+
* every configured host into a {@link DeployTarget} (each host's OWN token
|
|
392
|
+
* resolved env-only, LAZILY, via {@link resolveHostToken} — CLI > env, no file),
|
|
393
|
+
* and the site's `mode` from the matching `pinnace.json` site entry (overridable
|
|
394
|
+
* with `--mode`). A host with no resolvable token FAILS LOUD naming its exact
|
|
395
|
+
* env var. Prints the resulting CID / per-node breakdown.
|
|
396
|
+
*/
|
|
397
|
+
async function runDeploy(
|
|
398
|
+
argv: readonly string[],
|
|
399
|
+
rc: ResolvedRunContext,
|
|
400
|
+
): Promise<number> {
|
|
401
|
+
const {flags, positionals} = parseArgs(argv);
|
|
402
|
+
const [dir, siteId] = positionals;
|
|
403
|
+
if (!dir || !siteId) {
|
|
404
|
+
rc.err(
|
|
405
|
+
'pinnace deploy: usage: pinnace deploy [--mode ipfs|ipns] <dir> <id>',
|
|
406
|
+
);
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const cli = cliOverridesFromFlags(flags);
|
|
411
|
+
const cfg = resolveConfig({file: rc.file, env: rc.env, cli});
|
|
412
|
+
|
|
413
|
+
// The site's mode: --mode arg > matching site entry (config precedence).
|
|
414
|
+
const siteEntry = cfg.sites.find((s) => s.id === siteId);
|
|
415
|
+
const mode = (flags['mode'] as SiteMode | undefined) ?? siteEntry?.mode;
|
|
416
|
+
if (mode !== 'ipfs' && mode !== 'ipns') {
|
|
417
|
+
rc.err(
|
|
418
|
+
`pinnace deploy: mode for '${siteId}' is unset or invalid; pass --mode ipfs|ipns or add the site to pinnace.json`,
|
|
419
|
+
);
|
|
420
|
+
return 1;
|
|
421
|
+
}
|
|
422
|
+
if (cfg.hosts.length === 0) {
|
|
423
|
+
rc.err('pinnace deploy: no hosts configured (add hosts to pinnace.json)');
|
|
424
|
+
return 1;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Resolve each host's token env-only (LAZY: only the hosts this deploy uses).
|
|
428
|
+
// A missing token is a loud named error, never a silent "" / downstream 401.
|
|
429
|
+
let targets: DeployTarget[];
|
|
430
|
+
try {
|
|
431
|
+
targets = cfg.hosts.map((h) => ({
|
|
432
|
+
baseUrl: h.endpoint,
|
|
433
|
+
token: resolveHostToken({hostName: h.name, env: rc.env, cli}),
|
|
434
|
+
role: h.role,
|
|
435
|
+
}));
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (error instanceof MissingHostTokenError) {
|
|
438
|
+
rc.err(`pinnace deploy: ${error.message}`);
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
const input: DeployInput = {sourceDir: dir, id: siteId, mode, targets};
|
|
444
|
+
|
|
445
|
+
const result = await rc.deps.deploy(input);
|
|
446
|
+
rc.out(`cid: ${result.cid}`);
|
|
447
|
+
for (const ok of result.ok) {
|
|
448
|
+
rc.out(
|
|
449
|
+
` ok ${ok.baseUrl}${ok.published && ok.ipns ? ` (ipns ${ok.ipns})` : ''}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
for (const failure of result.failed) {
|
|
453
|
+
rc.err(` FAIL ${failure.baseUrl}: ${failure.error.message}`);
|
|
454
|
+
}
|
|
455
|
+
return result.success ? 0 : 1;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `install-ci --system <s> --build-command <c> --output-dir <d> [--branch <b>]
|
|
460
|
+
* [--node-version <v>]` -> core {@link ClientDeps.emitCi}. Prints the workflow
|
|
461
|
+
* path/contents and reports the secrets/vars the operator must set.
|
|
462
|
+
*/
|
|
463
|
+
function runInstallCi(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
464
|
+
const {flags} = parseArgs(argv);
|
|
465
|
+
const system = flags['system'];
|
|
466
|
+
const buildCommand = flags['build-command'];
|
|
467
|
+
const outputDir = flags['output-dir'];
|
|
468
|
+
const missing = missingFlags({
|
|
469
|
+
system,
|
|
470
|
+
'build-command': buildCommand,
|
|
471
|
+
'output-dir': outputDir,
|
|
472
|
+
});
|
|
473
|
+
if (missing.length > 0) {
|
|
474
|
+
rc.err(
|
|
475
|
+
`pinnace install-ci: missing required flag(s): ${missing.join(', ')}`,
|
|
476
|
+
);
|
|
477
|
+
return 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const input: EmitCiInput = {
|
|
481
|
+
system: system as CiSystem,
|
|
482
|
+
buildCommand,
|
|
483
|
+
outputDir,
|
|
484
|
+
};
|
|
485
|
+
if (flags['branch']) input.branch = flags['branch'];
|
|
486
|
+
if (flags['node-version']) input.nodeVersion = flags['node-version'];
|
|
487
|
+
|
|
488
|
+
const emitted = rc.deps.emitCi(input);
|
|
489
|
+
rc.out(`workflow: ${emitted.workflow.path}`);
|
|
490
|
+
rc.out(emitted.workflow.contents);
|
|
491
|
+
if (emitted.secrets.length > 0) {
|
|
492
|
+
rc.out('Required secrets (Settings -> Secrets):');
|
|
493
|
+
for (const s of emitted.secrets) rc.out(` ${s.name} — ${s.description}`);
|
|
494
|
+
}
|
|
495
|
+
if (emitted.vars.length > 0) {
|
|
496
|
+
rc.out('Required variables (Settings -> Variables):');
|
|
497
|
+
for (const v of emitted.vars) rc.out(` ${v.name} — ${v.description}`);
|
|
498
|
+
}
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* `status` -> core {@link ClientDeps.statusReport}, once per configured host
|
|
504
|
+
* (each node reports its OWN sites). Builds each node's Kubo client from the
|
|
505
|
+
* resolved endpoint + token and prints the per-site report.
|
|
506
|
+
*/
|
|
507
|
+
async function runStatus(
|
|
508
|
+
argv: readonly string[],
|
|
509
|
+
rc: ResolvedRunContext,
|
|
510
|
+
): Promise<number> {
|
|
511
|
+
const {flags} = parseArgs(argv);
|
|
512
|
+
const cli = cliOverridesFromFlags(flags);
|
|
513
|
+
const cfg = resolveConfig({file: rc.file, env: rc.env, cli});
|
|
514
|
+
if (cfg.hosts.length === 0) {
|
|
515
|
+
rc.err('pinnace status: no hosts configured (add hosts to pinnace.json)');
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const host of cfg.hosts) {
|
|
520
|
+
let token: string;
|
|
521
|
+
try {
|
|
522
|
+
// Env-only token, resolved LAZILY per host actually used (see deploy).
|
|
523
|
+
token = resolveHostToken({hostName: host.name, env: rc.env, cli});
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (error instanceof MissingHostTokenError) {
|
|
526
|
+
rc.err(`pinnace status: ${error.message}`);
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
const client = new KuboRpcClient({baseUrl: host.endpoint, token});
|
|
532
|
+
const report = await rc.deps.statusReport({client});
|
|
533
|
+
rc.out(`${host.name} (${host.endpoint}) peer ${report.peerId}`);
|
|
534
|
+
for (const site of report.sites) {
|
|
535
|
+
rc.out(
|
|
536
|
+
` ${site.id}: cid ${site.cid}${site.ipns ? ` ipns ${site.ipns}` : ''} announced=${site.announced} gatewayServes=${site.gatewayServes}`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* `derive <id>` (a.k.a. `ipns-id`) -> core {@link ClientDeps.deriveIpnsId}.
|
|
545
|
+
* Prints the site's `k51...` IPNS id from the master + the site's single `id`
|
|
546
|
+
* (the KDF input), with NO deploy (user story 22). The master is env-ONLY (via
|
|
547
|
+
* {@link resolveMasterSecret}); the `id` is either the positional argument
|
|
548
|
+
* verbatim or, if it names a `pinnace.json` site entry, that entry's `id` (they
|
|
549
|
+
* are the same value — one identifier). Fails loudly if the master is unset.
|
|
550
|
+
*/
|
|
551
|
+
function runDerive(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
552
|
+
const {positionals} = parseArgs(argv);
|
|
553
|
+
const [siteId] = positionals;
|
|
554
|
+
if (!siteId) {
|
|
555
|
+
rc.err('pinnace derive: usage: pinnace derive <id>');
|
|
556
|
+
return 1;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const master = resolveMasterSecret({env: rc.env});
|
|
560
|
+
if (!master) {
|
|
561
|
+
rc.err(
|
|
562
|
+
'pinnace derive: master secret not set — export PINNACE_MASTER (env-only; never read from pinnace.json)',
|
|
563
|
+
);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The single `id` IS the KDF input. The positional is the id directly; a
|
|
568
|
+
// matching site entry carries the same value (no separate keyId to look up).
|
|
569
|
+
const cfg = resolveConfig({file: rc.file, env: rc.env, cli: {}});
|
|
570
|
+
const id = cfg.sites.find((s) => s.id === siteId)?.id ?? siteId;
|
|
571
|
+
|
|
572
|
+
const printed = rc.deps.deriveIpnsId({master, keyId: id});
|
|
573
|
+
rc.out(printed);
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// Shared helpers.
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
/** Return the keys whose value is falsy (missing required flags), in order. */
|
|
582
|
+
function missingFlags(required: Record<string, string | undefined>): string[] {
|
|
583
|
+
return Object.entries(required)
|
|
584
|
+
.filter(([, value]) => !value)
|
|
585
|
+
.map(([key]) => `--${key}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Build the {@link CliOverrides} the config resolver understands from parsed
|
|
590
|
+
* flags. `--gateways a,b` overrides the gateway list; per-host token/endpoint
|
|
591
|
+
* overrides use the `--host-token.<name>` / `--host-endpoint.<name>` form.
|
|
592
|
+
*/
|
|
593
|
+
function cliOverridesFromFlags(flags: Record<string, string>): CliOverrides {
|
|
594
|
+
const cli: CliOverrides = {};
|
|
595
|
+
const hostToken: Record<string, string> = {};
|
|
596
|
+
const hostEndpoint: Record<string, string> = {};
|
|
597
|
+
for (const [key, value] of Object.entries(flags)) {
|
|
598
|
+
if (key.startsWith('host-token.'))
|
|
599
|
+
hostToken[key.slice('host-token.'.length)] = value;
|
|
600
|
+
else if (key.startsWith('host-endpoint.'))
|
|
601
|
+
hostEndpoint[key.slice('host-endpoint.'.length)] = value;
|
|
602
|
+
}
|
|
603
|
+
if (Object.keys(hostToken).length > 0) cli.hostToken = hostToken;
|
|
604
|
+
if (Object.keys(hostEndpoint).length > 0) cli.hostEndpoint = hostEndpoint;
|
|
605
|
+
if (flags['gateways'])
|
|
606
|
+
cli.gateways = flags['gateways']
|
|
607
|
+
.split(',')
|
|
608
|
+
.map((s) => s.trim())
|
|
609
|
+
.filter(Boolean);
|
|
610
|
+
return cli;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Parse `pinnace site <verb>` and validate the verb. The full context (Kubo
|
|
615
|
+
* client from config-resolution, the site name / CID args) is assembled by the
|
|
616
|
+
* CLI wiring in the site-management task; this thin router validates the verb
|
|
617
|
+
* belongs to the `site` namespace. The three verbs (list/remove/add) are
|
|
618
|
+
* implemented in `../site/site-management.ts`.
|
|
619
|
+
*/
|
|
620
|
+
function runSiteCli(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
621
|
+
const [verb] = argv;
|
|
622
|
+
if (!verb) {
|
|
623
|
+
rc.err(`pinnace site: expected a verb (${SITE_VERBS.join(', ')})`);
|
|
624
|
+
return 1;
|
|
625
|
+
}
|
|
626
|
+
if (!SITE_VERBS.includes(verb as SiteVerb)) {
|
|
627
|
+
rc.err(
|
|
628
|
+
`pinnace site: unknown verb '${verb}'; expected one of ${SITE_VERBS.join(', ')}`,
|
|
629
|
+
);
|
|
630
|
+
return 1;
|
|
631
|
+
}
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Parse `pinnace node <verb>` and validate the verb. The full on-box context
|
|
637
|
+
* (local Kubo client, role, on-box paths) is assembled by the cloud-init /
|
|
638
|
+
* config-resolution wiring in the node-agent-commands task; this thin router
|
|
639
|
+
* only validates the verb belongs to the `node` namespace.
|
|
640
|
+
*/
|
|
641
|
+
function runNodeCli(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
642
|
+
const [verb] = argv;
|
|
643
|
+
if (!verb) {
|
|
644
|
+
rc.err(`pinnace node: expected a verb (${NODE_VERBS.join(', ')})`);
|
|
645
|
+
return 1;
|
|
646
|
+
}
|
|
647
|
+
if (!NODE_VERBS.includes(verb as NodeVerb)) {
|
|
648
|
+
rc.err(
|
|
649
|
+
`pinnace node: unknown verb '${verb}'; expected one of ${NODE_VERBS.join(', ')}`,
|
|
650
|
+
);
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
return 0;
|
|
654
|
+
}
|