pinnace 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -6
- package/dist/cli/bin.js +8 -2
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/run.d.ts +16 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +245 -20
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/startup.d.ts +19 -0
- package/dist/cli/startup.d.ts.map +1 -0
- package/dist/cli/startup.js +46 -0
- package/dist/cli/startup.js.map +1 -0
- package/dist/node/node-commands.js +9 -5
- package/dist/node/node-commands.js.map +1 -1
- package/dist/provision/cloud-init.d.ts +25 -0
- package/dist/provision/cloud-init.d.ts.map +1 -1
- package/dist/provision/cloud-init.js +77 -7
- package/dist/provision/cloud-init.js.map +1 -1
- package/package.json +3 -2
- package/src/cli/bin.ts +8 -2
- package/src/cli/run.ts +336 -20
- package/src/cli/startup.ts +59 -0
- package/src/node/node-commands.ts +9 -5
- package/src/provision/cloud-init.ts +102 -7
package/src/cli/run.ts
CHANGED
|
@@ -15,14 +15,48 @@
|
|
|
15
15
|
* read or mutated — mirroring the `NodeCommandOps` injectable-ops pattern in
|
|
16
16
|
* node-commands and the explicit-`env` resolver in config-resolution).
|
|
17
17
|
*
|
|
18
|
-
* The on-box `pinnace node <verb>` and the `pinnace site <verb>` namespaces
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
21
26
|
*/
|
|
22
27
|
import {readFileSync} from 'node:fs';
|
|
23
28
|
import {name} from '../index.js';
|
|
24
|
-
import {
|
|
25
|
-
|
|
29
|
+
import {
|
|
30
|
+
NODE_VERBS,
|
|
31
|
+
runNodeCommand as coreRunNodeCommand,
|
|
32
|
+
type NodeVerb,
|
|
33
|
+
type NodeCommandContext,
|
|
34
|
+
type NodeCommandResult,
|
|
35
|
+
} from '../node/node-commands.js';
|
|
36
|
+
import {
|
|
37
|
+
SITE_VERBS,
|
|
38
|
+
listSites as coreListSites,
|
|
39
|
+
removeSite as coreRemoveSite,
|
|
40
|
+
addSite as coreAddSite,
|
|
41
|
+
type SiteVerb,
|
|
42
|
+
type ListSitesInput,
|
|
43
|
+
type SiteListing,
|
|
44
|
+
type RemoveSiteInput,
|
|
45
|
+
type RemoveSiteResult,
|
|
46
|
+
type AddSiteInput,
|
|
47
|
+
type AddSiteResult,
|
|
48
|
+
} from '../site/site-management.js';
|
|
49
|
+
import {makeStatusOp} from '../status/status-report.js';
|
|
50
|
+
import {
|
|
51
|
+
promoteReplicaToPublisher as corePromoteReplicaToPublisher,
|
|
52
|
+
type PromoteReplicaInput,
|
|
53
|
+
type PromoteReplicaResult,
|
|
54
|
+
} from '../publisher/record-sequence.js';
|
|
55
|
+
import {
|
|
56
|
+
deriveIpnsKey as coreDeriveIpnsKey,
|
|
57
|
+
type DeriveIpnsInput as DeriveIpnsKeyInput,
|
|
58
|
+
type DerivedIpnsKey,
|
|
59
|
+
} from '../derive/ipns-key-derivation.js';
|
|
26
60
|
import {KuboRpcClient} from '../rpc/kubo-rpc-client.js';
|
|
27
61
|
import {
|
|
28
62
|
provision as coreProvision,
|
|
@@ -81,6 +115,23 @@ export interface ClientDeps {
|
|
|
81
115
|
statusReport(input: StatusReportInput): Promise<StatusReport>;
|
|
82
116
|
/** `derive` -> the master + site `id` -> IPNS id derivation (no deploy). */
|
|
83
117
|
deriveIpnsId(input: DeriveIpnsInput): string;
|
|
118
|
+
/** `node <verb>` -> the on-box command runner (context assembled by the CLI). */
|
|
119
|
+
runNodeCommand(
|
|
120
|
+
verb: NodeVerb,
|
|
121
|
+
ctx: NodeCommandContext,
|
|
122
|
+
): Promise<NodeCommandResult>;
|
|
123
|
+
/** `site list` -> enumerate the node's MFS sites. */
|
|
124
|
+
listSites(input: ListSitesInput): Promise<SiteListing[]>;
|
|
125
|
+
/** `site remove` -> drop an MFS site + unpin its content. */
|
|
126
|
+
removeSite(input: RemoveSiteInput): Promise<RemoveSiteResult>;
|
|
127
|
+
/** `site add` -> place an already-imported CID into MFS as a site. */
|
|
128
|
+
addSite(input: AddSiteInput): Promise<AddSiteResult>;
|
|
129
|
+
/** `promote` -> derive the per-site key material from the master + `id`. */
|
|
130
|
+
deriveIpnsKey(input: DeriveIpnsKeyInput): DerivedIpnsKey;
|
|
131
|
+
/** `promote` -> import the key + flip the node's role to publisher (story 14). */
|
|
132
|
+
promoteReplicaToPublisher(
|
|
133
|
+
input: PromoteReplicaInput,
|
|
134
|
+
): Promise<PromoteReplicaResult>;
|
|
84
135
|
}
|
|
85
136
|
|
|
86
137
|
/** The real core, used when a caller does not inject stubs. */
|
|
@@ -90,6 +141,12 @@ const DEFAULT_DEPS: ClientDeps = {
|
|
|
90
141
|
emitCi: coreEmitCi,
|
|
91
142
|
statusReport: coreStatusReport,
|
|
92
143
|
deriveIpnsId: coreDeriveIpnsId,
|
|
144
|
+
runNodeCommand: coreRunNodeCommand,
|
|
145
|
+
listSites: coreListSites,
|
|
146
|
+
removeSite: coreRemoveSite,
|
|
147
|
+
addSite: coreAddSite,
|
|
148
|
+
deriveIpnsKey: coreDeriveIpnsKey,
|
|
149
|
+
promoteReplicaToPublisher: corePromoteReplicaToPublisher,
|
|
93
150
|
};
|
|
94
151
|
|
|
95
152
|
/**
|
|
@@ -263,6 +320,9 @@ export async function run(
|
|
|
263
320
|
if (command === 'derive' || command === 'ipns-id') {
|
|
264
321
|
return runDerive(rest, rc);
|
|
265
322
|
}
|
|
323
|
+
if (command === 'promote') {
|
|
324
|
+
return runPromote(rest, rc);
|
|
325
|
+
}
|
|
266
326
|
|
|
267
327
|
rc.err(`${name()}: unknown command '${command}'`);
|
|
268
328
|
return 1;
|
|
@@ -340,9 +400,11 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
340
400
|
|
|
341
401
|
/**
|
|
342
402
|
* `provision --host <h> --api-domain <d> --acme-email <e> --bearer-token <t>
|
|
343
|
-
* --role <r> [
|
|
344
|
-
*
|
|
345
|
-
*
|
|
403
|
+
* --role <r> [--pinnace-version <v>] [--node-major <n>] [--kubo-version <v>]
|
|
404
|
+
* [...]` -> core {@link ClientDeps.provision}. Purely arg-driven (provisioning
|
|
405
|
+
* inputs are per-box and not stored in `pinnace.json`); prints the generated
|
|
406
|
+
* cloud-init to stdout. The pinned pinnace/Node/Kubo versions default to the
|
|
407
|
+
* generator's named knobs and are overridable per-box via these flags.
|
|
346
408
|
*/
|
|
347
409
|
function runProvision(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
348
410
|
const {flags} = parseArgs(argv);
|
|
@@ -380,6 +442,9 @@ function runProvision(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
|
380
442
|
input.dashboardDomain = flags['dashboard-domain'];
|
|
381
443
|
if (flags['publisher-endpoint'])
|
|
382
444
|
input.publisherEndpoint = flags['publisher-endpoint'];
|
|
445
|
+
if (flags['kubo-version']) input.kuboVersion = flags['kubo-version'];
|
|
446
|
+
if (flags['pinnace-version']) input.pinnaceVersion = flags['pinnace-version'];
|
|
447
|
+
if (flags['node-major']) input.nodeMajor = flags['node-major'];
|
|
383
448
|
|
|
384
449
|
const result = rc.deps.provision(input);
|
|
385
450
|
rc.out(result.cloudInit.contents);
|
|
@@ -611,14 +676,22 @@ function cliOverridesFromFlags(flags: Record<string, string>): CliOverrides {
|
|
|
611
676
|
}
|
|
612
677
|
|
|
613
678
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
679
|
+
* `pinnace site <verb> [args] [--host <name>]` -> the site core
|
|
680
|
+
* ({@link ClientDeps.listSites}/{@link ClientDeps.removeSite}/
|
|
681
|
+
* {@link ClientDeps.addSite}). Assembles a per-host {@link KuboRpcClient} from
|
|
682
|
+
* the resolved config (endpoint) + the host's env-only token, then dispatches:
|
|
683
|
+
* - `list` -> listSites({client})
|
|
684
|
+
* - `remove <id>` -> removeSite({client, id})
|
|
685
|
+
* - `add <id> <cid>` -> addSite({client, id, cid})
|
|
686
|
+
* The `--host <name>` selects which configured node to act on; it may be
|
|
687
|
+
* omitted only when the config has exactly one host (see {@link selectHost}).
|
|
619
688
|
*/
|
|
620
|
-
function runSiteCli(
|
|
621
|
-
|
|
689
|
+
async function runSiteCli(
|
|
690
|
+
argv: readonly string[],
|
|
691
|
+
rc: ResolvedRunContext,
|
|
692
|
+
): Promise<number> {
|
|
693
|
+
const {flags, positionals} = parseArgs(argv);
|
|
694
|
+
const [verb, ...verbArgs] = positionals;
|
|
622
695
|
if (!verb) {
|
|
623
696
|
rc.err(`pinnace site: expected a verb (${SITE_VERBS.join(', ')})`);
|
|
624
697
|
return 1;
|
|
@@ -629,16 +702,68 @@ function runSiteCli(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
|
629
702
|
);
|
|
630
703
|
return 1;
|
|
631
704
|
}
|
|
705
|
+
|
|
706
|
+
const cli = cliOverridesFromFlags(flags);
|
|
707
|
+
const cfg = resolveConfig({file: rc.file, env: rc.env, cli});
|
|
708
|
+
const client = buildHostClient('pinnace site', flags['host'], cfg, rc, cli);
|
|
709
|
+
if (!client) return 1; // buildHostClient already emitted the loud error.
|
|
710
|
+
|
|
711
|
+
if (verb === 'list') {
|
|
712
|
+
const sites = await rc.deps.listSites({client});
|
|
713
|
+
for (const site of sites) {
|
|
714
|
+
rc.out(
|
|
715
|
+
`${site.id}: cid ${site.cid}${site.ipns ? ` ipns ${site.ipns}` : ''}`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
return 0;
|
|
719
|
+
}
|
|
720
|
+
if (verb === 'remove') {
|
|
721
|
+
const [id] = verbArgs;
|
|
722
|
+
if (!id) {
|
|
723
|
+
rc.err(
|
|
724
|
+
'pinnace site remove: usage: pinnace site remove <id> [--host <name>]',
|
|
725
|
+
);
|
|
726
|
+
return 1;
|
|
727
|
+
}
|
|
728
|
+
const result = await rc.deps.removeSite({client, id});
|
|
729
|
+
rc.out(
|
|
730
|
+
`removed ${result.id}${result.cid ? ` (cid ${result.cid}, unpinned=${result.unpinned})` : ''}`,
|
|
731
|
+
);
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
// verb === 'add'
|
|
735
|
+
const [id, cid] = verbArgs;
|
|
736
|
+
if (!id || !cid) {
|
|
737
|
+
rc.err(
|
|
738
|
+
'pinnace site add: usage: pinnace site add <id> <cid> [--host <name>]',
|
|
739
|
+
);
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
const result = await rc.deps.addSite({client, id, cid});
|
|
743
|
+
rc.out(`added ${result.id} -> ${result.cid}`);
|
|
632
744
|
return 0;
|
|
633
745
|
}
|
|
634
746
|
|
|
635
747
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
748
|
+
* `pinnace node <verb>` -> the on-box command core ({@link
|
|
749
|
+
* ClientDeps.runNodeCommand}). This is the load-bearing on-box wiring: the verb
|
|
750
|
+
* runs ON a provisioned box (invoked by the systemd timer, whose
|
|
751
|
+
* `EnvironmentFile=/etc/pinnace-node.env` exports the box config into the
|
|
752
|
+
* environment {@link RunContext.env} reads). It assembles a
|
|
753
|
+
* {@link NodeCommandContext} from that env — a LOCAL Kubo client (127.0.0.1:5001
|
|
754
|
+
* + `RPC_BEARER_TOKEN`), the box `role` (`NODE_ROLE`), and the on-box paths
|
|
755
|
+
* (`RECORDS_DIR`/`CACHE_DIR`/`DASHBOARD_DIR`/`SITES_DIR`), the replica
|
|
756
|
+
* `PUBLISHER_ENDPOINT`, and the `WARM_GATEWAYS` list — and injects the OWNED
|
|
757
|
+
* `status` op ({@link makeStatusOp}, the real per-site announce/gateway report)
|
|
758
|
+
* so the production status path is not the thin default stub. It then invokes
|
|
759
|
+
* the core (NOT a validate-and-return-0). A live run proved that without this
|
|
760
|
+
* the `republish`/`mirror` timers are no-ops. The token is env-only and its
|
|
761
|
+
* absence is a LOUD, named error (never a silent empty bearer / 401).
|
|
640
762
|
*/
|
|
641
|
-
function runNodeCli(
|
|
763
|
+
async function runNodeCli(
|
|
764
|
+
argv: readonly string[],
|
|
765
|
+
rc: ResolvedRunContext,
|
|
766
|
+
): Promise<number> {
|
|
642
767
|
const [verb] = argv;
|
|
643
768
|
if (!verb) {
|
|
644
769
|
rc.err(`pinnace node: expected a verb (${NODE_VERBS.join(', ')})`);
|
|
@@ -650,5 +775,196 @@ function runNodeCli(argv: readonly string[], rc: ResolvedRunContext): number {
|
|
|
650
775
|
);
|
|
651
776
|
return 1;
|
|
652
777
|
}
|
|
778
|
+
|
|
779
|
+
const env = rc.env;
|
|
780
|
+
const token = env['RPC_BEARER_TOKEN'];
|
|
781
|
+
if (!token) {
|
|
782
|
+
rc.err(
|
|
783
|
+
'pinnace node: RPC_BEARER_TOKEN not set — the on-box bearer is env-only ' +
|
|
784
|
+
'(read from /etc/pinnace-node.env); never a silent empty token',
|
|
785
|
+
);
|
|
786
|
+
return 1;
|
|
787
|
+
}
|
|
788
|
+
const role = env['NODE_ROLE'];
|
|
789
|
+
if (role !== 'publisher' && role !== 'replica') {
|
|
790
|
+
rc.err(
|
|
791
|
+
"pinnace node: NODE_ROLE must be 'publisher' or 'replica' " +
|
|
792
|
+
'(read from /etc/pinnace-node.env)',
|
|
793
|
+
);
|
|
794
|
+
return 1;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// The box's OWN daemon: the local Kubo RPC on 127.0.0.1:5001 (Caddy fronts it
|
|
798
|
+
// for external clients, but the on-box agent speaks to it directly).
|
|
799
|
+
const client = new KuboRpcClient({
|
|
800
|
+
baseUrl: 'http://127.0.0.1:5001',
|
|
801
|
+
token,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const ctx: NodeCommandContext = {
|
|
805
|
+
client,
|
|
806
|
+
role,
|
|
807
|
+
// The production status path uses the OWNED status op (real announce +
|
|
808
|
+
// gateway report), not the thin defaultStatus stand-in.
|
|
809
|
+
ops: {status: makeStatusOp()},
|
|
810
|
+
};
|
|
811
|
+
if (env['SITES_DIR']) ctx.sitesDir = env['SITES_DIR'];
|
|
812
|
+
if (env['RECORDS_DIR']) ctx.recordsDir = env['RECORDS_DIR'];
|
|
813
|
+
if (env['CACHE_DIR']) ctx.cacheDir = env['CACHE_DIR'];
|
|
814
|
+
if (env['DASHBOARD_DIR']) ctx.dashboardDir = env['DASHBOARD_DIR'];
|
|
815
|
+
if (env['PUBLISHER_ENDPOINT'])
|
|
816
|
+
ctx.publisherEndpoint = env['PUBLISHER_ENDPOINT'];
|
|
817
|
+
const gateways = splitWarmGateways(env['WARM_GATEWAYS']);
|
|
818
|
+
if (gateways.length > 0) ctx.gateways = gateways;
|
|
819
|
+
|
|
820
|
+
const result = await rc.deps.runNodeCommand(verb as NodeVerb, ctx);
|
|
821
|
+
if (result.skipped) {
|
|
822
|
+
rc.out(`pinnace node ${verb}: skipped (${result.skippedReason})`);
|
|
823
|
+
return 0;
|
|
824
|
+
}
|
|
825
|
+
for (const site of result.sites) {
|
|
826
|
+
rc.out(
|
|
827
|
+
` ${site.id}${site.cid ? ` cid ${site.cid}` : ''}${site.status ? ` (${site.status})` : ''}`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
653
830
|
return 0;
|
|
654
831
|
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* `pinnace promote <id> [--host <name>]` -> {@link
|
|
835
|
+
* ClientDeps.promoteReplicaToPublisher} (spec user story 14). Derives the
|
|
836
|
+
* per-site key from the env-only master + the site `id` (the KDF input,
|
|
837
|
+
* {@link ClientDeps.deriveIpnsKey}), assembles the chosen host's client, and
|
|
838
|
+
* promotes it: import the key + flip the role to publisher, recovering the name
|
|
839
|
+
* within the record's validity window without content downtime. The master is
|
|
840
|
+
* env-ONLY (never from `pinnace.json`); its absence is a LOUD error.
|
|
841
|
+
*/
|
|
842
|
+
async function runPromote(
|
|
843
|
+
argv: readonly string[],
|
|
844
|
+
rc: ResolvedRunContext,
|
|
845
|
+
): Promise<number> {
|
|
846
|
+
const {flags, positionals} = parseArgs(argv);
|
|
847
|
+
const [siteId] = positionals;
|
|
848
|
+
if (!siteId) {
|
|
849
|
+
rc.err('pinnace promote: usage: pinnace promote <id> [--host <name>]');
|
|
850
|
+
return 1;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const master = resolveMasterSecret({env: rc.env});
|
|
854
|
+
if (!master) {
|
|
855
|
+
rc.err(
|
|
856
|
+
'pinnace promote: master secret not set — export PINNACE_MASTER (env-only; never read from pinnace.json)',
|
|
857
|
+
);
|
|
858
|
+
return 1;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const cli = cliOverridesFromFlags(flags);
|
|
862
|
+
const cfg = resolveConfig({file: rc.file, env: rc.env, cli});
|
|
863
|
+
const host = pickHost('pinnace promote', flags['host'], cfg, rc);
|
|
864
|
+
if (!host) return 1;
|
|
865
|
+
const client = clientForHost('pinnace promote', host, rc, cli);
|
|
866
|
+
if (!client) return 1;
|
|
867
|
+
|
|
868
|
+
// The single `id` IS the KDF input (no separate keyId), matching `derive`.
|
|
869
|
+
const id = cfg.sites.find((s) => s.id === siteId)?.id ?? siteId;
|
|
870
|
+
const derived = rc.deps.deriveIpnsKey({master, keyId: id});
|
|
871
|
+
const result = await rc.deps.promoteReplicaToPublisher({
|
|
872
|
+
client,
|
|
873
|
+
currentRole: host.role,
|
|
874
|
+
keyName: id,
|
|
875
|
+
derived,
|
|
876
|
+
});
|
|
877
|
+
rc.out(
|
|
878
|
+
`promoted ${result.keyName} to ${result.role}${result.ipns ? ` (ipns ${result.ipns})` : ''}`,
|
|
879
|
+
);
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
// Host selection + client assembly (shared by site + promote).
|
|
885
|
+
// ---------------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Pick the configured host to act on. `--host <name>` selects it by name; it
|
|
889
|
+
* may be omitted ONLY when the config has exactly one host (the unambiguous
|
|
890
|
+
* case). Zero hosts, an unknown name, or an omitted name with several hosts is
|
|
891
|
+
* a LOUD, specific error (returns undefined after emitting it).
|
|
892
|
+
*/
|
|
893
|
+
function pickHost(
|
|
894
|
+
prefix: string,
|
|
895
|
+
hostName: string | undefined,
|
|
896
|
+
cfg: {hosts: Array<{name: string; endpoint: string; role: HostRole}>},
|
|
897
|
+
rc: ResolvedRunContext,
|
|
898
|
+
): {name: string; endpoint: string; role: HostRole} | undefined {
|
|
899
|
+
if (cfg.hosts.length === 0) {
|
|
900
|
+
rc.err(`${prefix}: no hosts configured (add hosts to pinnace.json)`);
|
|
901
|
+
return undefined;
|
|
902
|
+
}
|
|
903
|
+
if (hostName) {
|
|
904
|
+
const match = cfg.hosts.find((h) => h.name === hostName);
|
|
905
|
+
if (!match) {
|
|
906
|
+
rc.err(
|
|
907
|
+
`${prefix}: unknown host '${hostName}'; configured hosts: ${cfg.hosts
|
|
908
|
+
.map((h) => h.name)
|
|
909
|
+
.join(', ')}`,
|
|
910
|
+
);
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
return match;
|
|
914
|
+
}
|
|
915
|
+
if (cfg.hosts.length > 1) {
|
|
916
|
+
rc.err(
|
|
917
|
+
`${prefix}: multiple hosts configured; pass --host <name> (one of ${cfg.hosts
|
|
918
|
+
.map((h) => h.name)
|
|
919
|
+
.join(', ')})`,
|
|
920
|
+
);
|
|
921
|
+
return undefined;
|
|
922
|
+
}
|
|
923
|
+
return cfg.hosts[0];
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Build a {@link KuboRpcClient} for a chosen host, resolving its bearer token
|
|
928
|
+
* env-only (CLI > env, no file). A missing token is a LOUD, named error
|
|
929
|
+
* (returns undefined after emitting it).
|
|
930
|
+
*/
|
|
931
|
+
function clientForHost(
|
|
932
|
+
prefix: string,
|
|
933
|
+
host: {name: string; endpoint: string},
|
|
934
|
+
rc: ResolvedRunContext,
|
|
935
|
+
cli: CliOverrides,
|
|
936
|
+
): KuboRpcClient | undefined {
|
|
937
|
+
let token: string;
|
|
938
|
+
try {
|
|
939
|
+
token = resolveHostToken({hostName: host.name, env: rc.env, cli});
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if (error instanceof MissingHostTokenError) {
|
|
942
|
+
rc.err(`${prefix}: ${error.message}`);
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
throw error;
|
|
946
|
+
}
|
|
947
|
+
return new KuboRpcClient({baseUrl: host.endpoint, token});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Pick a host AND build its client in one step (the common site path). */
|
|
951
|
+
function buildHostClient(
|
|
952
|
+
prefix: string,
|
|
953
|
+
hostName: string | undefined,
|
|
954
|
+
cfg: {hosts: Array<{name: string; endpoint: string; role: HostRole}>},
|
|
955
|
+
rc: ResolvedRunContext,
|
|
956
|
+
cli: CliOverrides,
|
|
957
|
+
): KuboRpcClient | undefined {
|
|
958
|
+
const host = pickHost(prefix, hostName, cfg, rc);
|
|
959
|
+
if (!host) return undefined;
|
|
960
|
+
return clientForHost(prefix, host, rc, cli);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Split the space-separated `WARM_GATEWAYS` env value into a template list. */
|
|
964
|
+
function splitWarmGateways(value: string | undefined): string[] {
|
|
965
|
+
if (!value) return [];
|
|
966
|
+
return value
|
|
967
|
+
.split(/\s+/)
|
|
968
|
+
.map((s) => s.trim())
|
|
969
|
+
.filter(Boolean);
|
|
970
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `pinnace` CLI STARTUP shim: the thin process-entry glue that runs BEFORE
|
|
3
|
+
* the pure {@link run} dispatch. Its whole job is to make the env-only secrets
|
|
4
|
+
* model (CONTEXT.md `master key` / `token`) ergonomic for EVERY user, including
|
|
5
|
+
* a global `npm install -g pinnace`, by loading `.env` / `.env.local` from the
|
|
6
|
+
* cwd into `process.env` at startup, so no `pnpm ldenv` dev wrapper is needed.
|
|
7
|
+
*
|
|
8
|
+
* WHY a shim (and not inside {@link run}): `run(argv, {env})` is pure and
|
|
9
|
+
* hermetically unit-tested with an INJECTED `env` record; it must never read a
|
|
10
|
+
* real `.env` / touch a real cwd. So the ONE impure, filesystem-touching step
|
|
11
|
+
* (`ldenv`'s {@link loadEnv}) lives HERE, in the bin/startup layer, in front of
|
|
12
|
+
* `run()`. Config resolution + {@link resolveMasterSecret} read `process.env`,
|
|
13
|
+
* so `loadEnv()` must have mutated it BEFORE `run()` runs.
|
|
14
|
+
*
|
|
15
|
+
* PRECEDENCE (preserved, not changed). `loadEnv()` reads `.env` then
|
|
16
|
+
* `.env.local` (local overriding default) and MUTATES `process.env`, but it
|
|
17
|
+
* does NOT override a value ALREADY set in `process.env`. So dotenv slots in
|
|
18
|
+
* BELOW an explicitly-exported env var and ABOVE `pinnace.json`, keeping the
|
|
19
|
+
* documented "env > file" rule intact. The effective chain becomes:
|
|
20
|
+
* **CLI arg > exported `process.env` > `.env.local` > `.env` > `pinnace.json`**.
|
|
21
|
+
* The master + host tokens stay env-only: `.env.local` is merely a FILE SOURCE
|
|
22
|
+
* for those env vars, never a `pinnace.json` field.
|
|
23
|
+
*
|
|
24
|
+
* The `loadEnv` call is injected (defaulting to the real `ldenv` one) purely so
|
|
25
|
+
* the shim is unit-testable; production always uses the real `ldenv.loadEnv`,
|
|
26
|
+
* which is cwd-based only (never a global/home location), silent, and only
|
|
27
|
+
* AUGMENTS env — an already-exported value is left untouched.
|
|
28
|
+
*/
|
|
29
|
+
import {loadEnv} from 'ldenv';
|
|
30
|
+
import {run} from './run.js';
|
|
31
|
+
|
|
32
|
+
/** Injectable seams for the startup shim (defaulted to the real ones). */
|
|
33
|
+
export interface StartupOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Load `.env` / `.env.local` from the cwd into `process.env`. Defaults to
|
|
36
|
+
* `ldenv`'s `loadEnv` (reads `.env` then `.env.local`, local overriding,
|
|
37
|
+
* never overriding an already-exported value). Injected only for tests.
|
|
38
|
+
*/
|
|
39
|
+
loadDotEnv?: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The CLI startup entry: load the cwd's `.env` / `.env.local` into
|
|
44
|
+
* `process.env` (so a global install gets the env-only secrets), THEN dispatch
|
|
45
|
+
* `run()` over the process argv. Returns the process exit code.
|
|
46
|
+
*
|
|
47
|
+
* This is what the `pinnace` bin calls. It keeps the `loadEnv()` side effect
|
|
48
|
+
* out of the pure `run()` path so `run(argv, {env})` stays hermetic.
|
|
49
|
+
*/
|
|
50
|
+
export async function main(
|
|
51
|
+
argv: readonly string[] = process.argv.slice(2),
|
|
52
|
+
options: StartupOptions = {},
|
|
53
|
+
): Promise<number> {
|
|
54
|
+
const loadDotEnv = options.loadDotEnv ?? (() => void loadEnv());
|
|
55
|
+
// Load the cwd's dotenv files BEFORE run() reads process.env. This only
|
|
56
|
+
// augments process.env; an already-exported value still wins.
|
|
57
|
+
loadDotEnv();
|
|
58
|
+
return run(argv);
|
|
59
|
+
}
|
|
@@ -313,11 +313,15 @@ async function defaultWarm(
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/**
|
|
316
|
-
* Default `status
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
316
|
+
* Default `status` — a thin stand-in used ONLY when no `status` op is injected.
|
|
317
|
+
* The PRODUCTION on-box path (`pinnace node status`, wired in cli/run.ts's
|
|
318
|
+
* `runNodeCli`) injects the OWNED `status` op ({@link makeStatusOp} from
|
|
319
|
+
* status-report) via {@link NodeCommandContext.ops}.status, which is the real
|
|
320
|
+
* per-site CID/IPNS/announce/gateway report. This default remains as the
|
|
321
|
+
* seam's safe fallback (e.g. a direct `runNodeCommand` call that does not want
|
|
322
|
+
* the live external checks / a hermetic test that injects its own op), so a
|
|
323
|
+
* bare call still reports current CID + IPNS id without reaching the network.
|
|
324
|
+
* The dashboard write is done by the command layer ({@link runNodeCommand}).
|
|
321
325
|
*/
|
|
322
326
|
async function defaultStatus(
|
|
323
327
|
ctx: NodeCommandContext,
|