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/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 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).
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 {NODE_VERBS, type NodeVerb} from '../node/node-commands.js';
25
- import {SITE_VERBS, type SiteVerb} from '../site/site-management.js';
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> [...]` -> 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.
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
- * 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`.
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(argv: readonly string[], rc: ResolvedRunContext): number {
621
- const [verb] = argv;
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
- * 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.
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(argv: readonly string[], rc: ResolvedRunContext): number {
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`. Reuses the `status-report` core logic (injected via
317
- * {@link NodeCommandContext.ops}.status; this default is a thin stand-in until
318
- * that task lands) and writes the resulting report as `status.json` under the
319
- * box's dashboard directory ONLY. Reporting per-site CID / IPNS id happens in
320
- * the reused core; this wrapper just persists it where the dashboard reads.
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,