@tinycloud/cli 1.2.0-beta.1 → 1.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,6 +8,114 @@ var __export = (target, all) => {
8
8
  __defProp(target, name, { get: all[name], enumerable: true });
9
9
  };
10
10
 
11
+ // src/lib/replication-registry.ts
12
+ function stopForSignal(signal) {
13
+ void closeReplication().finally(() => {
14
+ process.exit(signal === "SIGINT" ? 130 : 143);
15
+ });
16
+ }
17
+ function installSignalHandlers() {
18
+ if (state.installedSignalHandlers) return;
19
+ state.installedSignalHandlers = true;
20
+ state.sigintHandler = () => stopForSignal("SIGINT");
21
+ state.sigtermHandler = () => stopForSignal("SIGTERM");
22
+ process.once("SIGINT", state.sigintHandler);
23
+ process.once("SIGTERM", state.sigtermHandler);
24
+ }
25
+ function removeSignalHandlers() {
26
+ if (!state.installedSignalHandlers) return;
27
+ state.installedSignalHandlers = false;
28
+ if (state.sigintHandler) process.removeListener("SIGINT", state.sigintHandler);
29
+ if (state.sigtermHandler) process.removeListener("SIGTERM", state.sigtermHandler);
30
+ state.sigintHandler = void 0;
31
+ state.sigtermHandler = void 0;
32
+ }
33
+ function registerReplication(profile, control) {
34
+ state.controls.set(profile, control);
35
+ state.allControls.add(control);
36
+ state.generation += 1;
37
+ installSignalHandlers();
38
+ }
39
+ function replicationForProfile(profile) {
40
+ return state.controls.get(profile);
41
+ }
42
+ function registeredReplications() {
43
+ return state.controls;
44
+ }
45
+ function closeReplication(schedule = scheduleTimeout) {
46
+ if (state.closing) return state.closing;
47
+ removeSignalHandlers();
48
+ let resolveClosing;
49
+ let rejectClosing;
50
+ const closing = new Promise((resolve2, reject) => {
51
+ resolveClosing = resolve2;
52
+ rejectClosing = reject;
53
+ });
54
+ state.closing = closing;
55
+ void (async () => {
56
+ let timeout;
57
+ const deadline = new Promise((resolve2) => {
58
+ timeout = schedule(() => resolve2(true), CLOSE_TIMEOUT_MS);
59
+ });
60
+ let timedOut = false;
61
+ const abandonQueuedControls = () => {
62
+ for (const control of state.allControls) {
63
+ void Promise.resolve().then(() => control.close()).catch(() => void 0);
64
+ }
65
+ state.controls.clear();
66
+ state.allControls.clear();
67
+ };
68
+ try {
69
+ while (!timedOut) {
70
+ while (state.allControls.size > 0) {
71
+ const generation = state.generation;
72
+ const pending = [...state.allControls];
73
+ state.controls.clear();
74
+ state.allControls.clear();
75
+ const drained = Promise.allSettled(pending.map((control) => Promise.resolve().then(() => control.close()))).then(() => false);
76
+ timedOut ||= await Promise.race([drained, deadline]);
77
+ if (timedOut) {
78
+ abandonQueuedControls();
79
+ break;
80
+ }
81
+ if (state.generation === generation && state.allControls.size === 0) break;
82
+ }
83
+ if (timedOut) break;
84
+ const turn = new Promise((resolve2) => setImmediate(() => resolve2(false)));
85
+ timedOut ||= await Promise.race([turn, deadline]);
86
+ if (timedOut) abandonQueuedControls();
87
+ else if (state.allControls.size === 0) break;
88
+ }
89
+ state.closing = void 0;
90
+ if (state.allControls.size === 0) removeSignalHandlers();
91
+ resolveClosing(timedOut);
92
+ } catch (error) {
93
+ state.closing = void 0;
94
+ if (state.allControls.size === 0) removeSignalHandlers();
95
+ rejectClosing(error);
96
+ } finally {
97
+ clearTimeout(timeout);
98
+ }
99
+ })();
100
+ return closing;
101
+ }
102
+ var registryKey, globalState, state, CLOSE_TIMEOUT_MS, scheduleTimeout;
103
+ var init_replication_registry = __esm({
104
+ "src/lib/replication-registry.ts"() {
105
+ "use strict";
106
+ registryKey = /* @__PURE__ */ Symbol.for("@tinycloud/cli/replication-registry");
107
+ globalState = globalThis;
108
+ state = globalState[registryKey] ??= {
109
+ controls: /* @__PURE__ */ new Map(),
110
+ allControls: /* @__PURE__ */ new Set(),
111
+ installedSignalHandlers: false,
112
+ generation: 0
113
+ };
114
+ CLOSE_TIMEOUT_MS = 3e3;
115
+ scheduleTimeout = (callback, delayMs) => setTimeout(callback, delayMs);
116
+ }
117
+ });
118
+
11
119
  // src/config/constants.ts
12
120
  import {
13
121
  profilesPath,
@@ -33,8 +141,12 @@ var init_constants = __esm({
33
141
  PERMISSION_DENIED: 5,
34
142
  NETWORK_ERROR: 6,
35
143
  NODE_ERROR: 7,
144
+ /** `tc secrets wait` only: the deadline passed while a secret was still missing. */
145
+ PENDING: 8,
36
146
  /** A write was refused because the owner's TinyCloud storage is full. */
37
- STORAGE_FULL: 10
147
+ STORAGE_FULL: 10,
148
+ /** Interrupted by SIGINT/SIGTERM (`CANCELLED`). */
149
+ CANCELLED: 130
38
150
  };
39
151
  }
40
152
  });
@@ -119,7 +231,7 @@ function warningLine(warnings) {
119
231
  return `${subject} not used (${which}). Approve access again when a command needs it; remove the old grant with tc auth grants remove <cid>.`;
120
232
  }
121
233
  function outputError(code3, message, hint, details = {}) {
122
- const { meta, warnings = [] } = details;
234
+ const { meta, warnings = [], setup, recovery, progress } = details;
123
235
  if (!shouldOutputJson()) {
124
236
  process.stderr.write(
125
237
  `${theme.error("\u2717")} ${theme.label(code3)}: ${message}
@@ -138,6 +250,9 @@ function outputError(code3, message, hint, details = {}) {
138
250
  error: { code: code3, message }
139
251
  };
140
252
  if (hint) payload.error.hint = hint;
253
+ if (recovery) payload.error.recovery = recovery;
254
+ if (setup) payload.error.setup = setup;
255
+ if (progress) payload.error.progress = progress;
141
256
  if (meta) payload.error.meta = meta;
142
257
  if (warnings.length > 0) payload.error.warnings = warnings;
143
258
  process.stderr.write(JSON.stringify(payload, null, 2) + "\n");
@@ -267,6 +382,13 @@ var init_storage = __esm({
267
382
  import { readFileSync, readdirSync } from "fs";
268
383
  import { join } from "path";
269
384
  import { ProfileDeletedError, ProfileLockTimeoutError } from "@tinycloud/operations/state";
385
+ import { isSafeRecovery } from "@tinycloud/operations/recovery";
386
+ import {
387
+ buildSecretRequestUrl,
388
+ isSafeRequestAccessRecovery,
389
+ isValidSecretName,
390
+ SECRET_REQUEST_MAX_NAMES
391
+ } from "@tinycloud/operations/secret-request";
270
392
  import { authorizationVerdictOf, parseCapabilityResource, SERVICE_LONG_TO_SHORT, validatedCapabilityOf } from "@tinycloud/sdk-core";
271
393
  function setActiveProfileName(name) {
272
394
  activeProfileName = name;
@@ -342,11 +464,64 @@ function handleError(error) {
342
464
  meta.resource = capability.resource;
343
465
  meta.requiredAction = capability.requiredAction;
344
466
  }
467
+ const deniedNames = cliError.metadata?.deniedNames;
468
+ const recovery = safeCliRecovery(cliError.metadata?.recovery, {
469
+ deniedNames: Array.isArray(deniedNames) && deniedNames.every((name) => typeof name === "string") ? deniedNames : []
470
+ });
471
+ const setup = cliError.code === "NOT_FOUND" || cliError.code === "PERMISSION_DENIED" ? safeCliSetup(cliError.metadata?.setup) : void 0;
472
+ const progress = cliError.code === "NETWORK_ERROR" || cliError.code === "CANCELLED" ? safeCliProgress(cliError.metadata?.progress) : void 0;
345
473
  outputError(cliError.code, cliError.message, hint, {
474
+ ...recovery ? { recovery } : {},
475
+ ...setup ? { setup } : {},
476
+ ...progress ? { progress } : {},
346
477
  ...Object.keys(meta).length ? { meta } : {},
347
478
  warnings: mergeWarnings(drainWarnings(), operationWarnings(cliError.metadata?.warnings))
348
479
  });
349
- process.exit(cliError.exitCode);
480
+ if (registeredReplications().size === 0) return process.exit(cliError.exitCode);
481
+ return closeReplication().then(() => process.exit(cliError.exitCode));
482
+ }
483
+ function safeCliRecovery(value, context) {
484
+ if (typeof value !== "object" || value === null || Array.isArray(value) || !("action" in value)) return void 0;
485
+ const action = value.action;
486
+ switch (action) {
487
+ case "sign_in":
488
+ case "complete_migration":
489
+ return isSafeRecovery(value, action) ? { ...value } : void 0;
490
+ case "request_access":
491
+ return isSafeRequestAccessRecovery(value, context.deniedNames) ? { ...value, names: [...value.names] } : void 0;
492
+ default:
493
+ return void 0;
494
+ }
495
+ }
496
+ function safeCliSetup(value) {
497
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
498
+ const setup = { ...value };
499
+ if (!Object.keys(setup).every((key) => SETUP_KEYS[key] === true)) return void 0;
500
+ const { url, names, scope, owner, action } = setup;
501
+ if (action !== "add_secret" || typeof url !== "string" || typeof owner !== "string") return void 0;
502
+ if (!Array.isArray(names) || !names.every((name) => typeof name === "string")) return void 0;
503
+ if (scope !== void 0 && typeof scope !== "string") return void 0;
504
+ try {
505
+ const link2 = buildSecretRequestUrl({ owner, names, ...scope !== void 0 ? { scope } : {} });
506
+ if (link2.url !== url || link2.owner !== owner || link2.names.length !== names.length) return void 0;
507
+ } catch {
508
+ return void 0;
509
+ }
510
+ return { url, names: [...names], ...scope !== void 0 ? { scope } : {}, owner, action };
511
+ }
512
+ function safeNameList(value) {
513
+ if (!Array.isArray(value) || value.length > SECRET_REQUEST_MAX_NAMES) return void 0;
514
+ if (!value.every((name) => typeof name === "string" && isValidSecretName(name))) return void 0;
515
+ if (new Set(value).size !== value.length) return void 0;
516
+ return [...value];
517
+ }
518
+ function safeCliProgress(value) {
519
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
520
+ if (!("present" in value) || !("pending" in value) || Object.keys(value).length !== 2) return void 0;
521
+ const present = safeNameList(value.present);
522
+ const pending = safeNameList(value.pending);
523
+ if (!present || !pending || present.some((name) => pending.includes(name))) return void 0;
524
+ return { present, pending };
350
525
  }
351
526
  function buildAuthHint(error) {
352
527
  const capability = validatedCapabilityOf({ meta: error.metadata });
@@ -409,10 +584,11 @@ function buildNetworkHint() {
409
584
  lines.push(`Or override per-command with --host or TC_HOST.`);
410
585
  return lines.join("\n");
411
586
  }
412
- var activeProfileName, CLIError;
587
+ var activeProfileName, CLIError, SETUP_KEYS;
413
588
  var init_errors = __esm({
414
589
  "src/output/errors.ts"() {
415
590
  "use strict";
591
+ init_replication_registry();
416
592
  init_constants();
417
593
  init_formatter();
418
594
  init_storage();
@@ -428,6 +604,7 @@ var init_errors = __esm({
428
604
  }
429
605
  status;
430
606
  };
607
+ SETUP_KEYS = { url: true, names: true, scope: true, owner: true, action: true };
431
608
  }
432
609
  });
433
610
 
@@ -900,11 +1077,11 @@ var init_md = __esm({
900
1077
  if (len % 4)
901
1078
  throw new Error("_sha2: outputLen should be aligned to 32bit");
902
1079
  const outLen = len / 4;
903
- const state = this.get();
904
- if (outLen > state.length)
1080
+ const state2 = this.get();
1081
+ if (outLen > state2.length)
905
1082
  throw new Error("_sha2: outputLen bigger than state");
906
1083
  for (let i = 0; i < outLen; i++)
907
- oview.setUint32(4 * i, state[i], isLE2);
1084
+ oview.setUint32(4 * i, state2[i], isLE2);
908
1085
  }
909
1086
  digest() {
910
1087
  const { buffer, outputLen } = this;
@@ -5464,6 +5641,8 @@ import {
5464
5641
  buildPermissionRequestArtifact,
5465
5642
  isPermissionRequestArtifact
5466
5643
  } from "@tinycloud/operations/artifacts";
5644
+ import { isLocalOwnerProfile } from "@tinycloud/operations/profile";
5645
+ import { resolveSecretsOwner } from "@tinycloud/operations/secret-request";
5467
5646
  import {
5468
5647
  additionalDelegationsPath as sharedAdditionalDelegationsPath,
5469
5648
  authRequestsPath as sharedAuthRequestsPath,
@@ -5501,14 +5680,17 @@ var init_permissions = __esm({
5501
5680
  });
5502
5681
 
5503
5682
  // src/auth/session-expired.ts
5683
+ import { signInRecovery } from "@tinycloud/operations/recovery";
5504
5684
  function signInAgainHint(profileName, posture) {
5505
- if (posture === "local-owner-key") {
5506
- return `Sign in again with: tc --profile ${profileName} auth login --method local`;
5507
- }
5508
- if (posture === "delegate-session") {
5509
- return `Have the owner approve a new scoped login: tc --profile ${profileName} auth login --method openkey --paste --manifest <manifest.json>. Pass the owner's code on stdin, newline-terminated.`;
5685
+ const delegate = posture === "delegate-session";
5686
+ const { command } = signInRecovery(
5687
+ profileName,
5688
+ delegate ? "delegate-session" : posture === "local-owner-key" ? "local-owner-key" : "owner-openkey"
5689
+ );
5690
+ if (command === void 0) {
5691
+ return delegate ? `Have the owner approve a new scoped login for profile ${JSON.stringify(profileName)} with tc auth login --method openkey --paste --manifest <manifest.json>.` : `Sign in again to profile ${JSON.stringify(profileName)} with tc auth login.`;
5510
5692
  }
5511
- return `Sign in again with: tc --profile ${profileName} auth login --method openkey`;
5693
+ return delegate ? `Have the owner approve a new scoped login: ${command}. Pass the owner's code on stdin, newline-terminated.` : `Sign in again with: ${command}`;
5512
5694
  }
5513
5695
  function sessionExpiredError(profileName, posture) {
5514
5696
  return new CLIError(
@@ -5543,13 +5725,127 @@ var init_delegation_expiry = __esm({
5543
5725
  }
5544
5726
  });
5545
5727
 
5728
+ // src/lib/replication-log.ts
5729
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync, writeFileSync } from "fs";
5730
+ import { dirname as dirname2, join as join4 } from "path";
5731
+ function appendReplicationEvent(root, event) {
5732
+ mkdirSync(root, { recursive: true, mode: DIR_MODE });
5733
+ chmodSync(root, DIR_MODE);
5734
+ const path = join4(root, "events.jsonl");
5735
+ let size = 0;
5736
+ try {
5737
+ size = statSync(path).size;
5738
+ } catch (error) {
5739
+ if (error.code !== "ENOENT") throw error;
5740
+ }
5741
+ const line = `${JSON.stringify(event)}
5742
+ `;
5743
+ if (size + Buffer.byteLength(line) > MAX_LOG_BYTES) {
5744
+ const rotated = join4(root, "events.jsonl.1");
5745
+ try {
5746
+ renameSync(path, rotated);
5747
+ } catch (error) {
5748
+ if (error.code !== "ENOENT") throw error;
5749
+ }
5750
+ writeFileSync(path, line, { mode: LOG_MODE });
5751
+ } else {
5752
+ appendFileSync(path, line, { mode: LOG_MODE });
5753
+ }
5754
+ chmodSync(path, LOG_MODE);
5755
+ }
5756
+ function replicationLogPath(profileRoot) {
5757
+ return join4(profileRoot, "replication", "events.jsonl");
5758
+ }
5759
+ function createReplicationEventSink(profileRoot, options) {
5760
+ const pinnedNotices = /* @__PURE__ */ new Set();
5761
+ const missingGrantWarnings = /* @__PURE__ */ new Set();
5762
+ return (event) => {
5763
+ appendProfileReplicationEvent(profileRoot, event);
5764
+ if (event.type === "replication.state" && event.state === "grant_missing") {
5765
+ const prefix = event.replica ?? event.space ?? "configured prefix";
5766
+ const key = `${event.space ?? ""}\0${event.replica ?? ""}`;
5767
+ if (!missingGrantWarnings.has(key)) {
5768
+ missingGrantWarnings.add(key);
5769
+ process.stderr.write(`[replication] Warning: no usable kv/get+sync grant for ${prefix}; reads use the network (${event.code ?? "grant_missing"}).
5770
+ `);
5771
+ }
5772
+ }
5773
+ if (options.debug) {
5774
+ if (event.type === "replication.read") {
5775
+ const detail = event.source === "replica" ? "replica hit" : `${event.source} ${event.reason}`;
5776
+ const syncStatus = event.syncedBeforeRead === void 0 ? "" : ` syncedBeforeRead:${event.syncedBeforeRead}`;
5777
+ const syncError = event.syncError === void 0 ? "" : ` syncError:${event.syncError}`;
5778
+ process.stderr.write(`[replication] ${event.op} ${event.key} \u2190 ${detail} ${event.latencyMs}ms${syncStatus}${syncError}${event.stalenessMs === null ? "" : ` (synced ${Math.round(event.stalenessMs / 1e3)}s ago)`}
5779
+ `);
5780
+ } else if (event.type === "replication.write") {
5781
+ process.stderr.write(`[replication] ${event.op} ${event.keys.join(",")} ${event.outcome}${event.code ? ` ${event.code}` : ""}
5782
+ `);
5783
+ } else if (event.type === "replication.sync") {
5784
+ const pending = event.pendingCleared === void 0 ? "" : ` pendingCleared:${event.pendingCleared}`;
5785
+ process.stderr.write(`[replication] sync ${event.replica} ${event.outcome}${event.code ? ` ${event.code}` : ""}${pending}
5786
+ `);
5787
+ }
5788
+ }
5789
+ if (event.type === "replication.read" && (event.pendingState === "in_flight" || event.pendingState === "ambiguous") && !options.quiet && !pinnedNotices.has(event.key)) {
5790
+ pinnedNotices.add(event.key);
5791
+ process.stderr.write(`[replication] ${event.key} reads from the network: an earlier write's outcome is unknown (${event.pendingState}${event.code ? `, ${event.code}` : ""}). Review: tc replica report
5792
+ `);
5793
+ }
5794
+ };
5795
+ }
5796
+ function appendProfileReplicationEvent(profileRoot, event) {
5797
+ appendReplicationEvent(dirname2(replicationLogPath(profileRoot)), event);
5798
+ }
5799
+ var MAX_LOG_BYTES, LOG_MODE, DIR_MODE;
5800
+ var init_replication_log = __esm({
5801
+ "src/lib/replication-log.ts"() {
5802
+ "use strict";
5803
+ MAX_LOG_BYTES = 5 * 1024 * 1024;
5804
+ LOG_MODE = 384;
5805
+ DIR_MODE = 448;
5806
+ }
5807
+ });
5808
+
5809
+ // src/lib/replication-config.ts
5810
+ import { join as join5 } from "path";
5811
+ import { profilePath, withProfileLock as withProfileLock2 } from "@tinycloud/operations/state";
5812
+ import { sqliteReplicaStorage } from "@tinycloud/node-sdk";
5813
+ function createCliReplicationConfig(profile, settings, output, enabled) {
5814
+ if (!enabled || !settings || settings.prefixes.length === 0) return void 0;
5815
+ const root = profilePath(profile);
5816
+ return {
5817
+ enabled: true,
5818
+ prefixes: settings.prefixes,
5819
+ allowSecrets: settings.allowSecrets === true,
5820
+ mode: "foreground",
5821
+ verify: process.env.TC_REPLICATION_VERIFY === "1",
5822
+ maxStalenessMs: Number(process.env.TC_REPLICATION_MAX_STALENESS_MS ?? 6e4),
5823
+ staleSyncTimeoutMs: Number(process.env.TC_REPLICATION_SYNC_TIMEOUT_MS ?? 1e4),
5824
+ storage: sqliteReplicaStorage({
5825
+ dir: join5(root, "replication"),
5826
+ guard: (section) => withProfileLock2(profile, section, { requireProfile: true, timeoutMs: 35e3 })
5827
+ }),
5828
+ onEvent: createReplicationEventSink(root, output)
5829
+ };
5830
+ }
5831
+ var init_replication_config = __esm({
5832
+ "src/lib/replication-config.ts"() {
5833
+ "use strict";
5834
+ init_replication_log();
5835
+ }
5836
+ });
5837
+
5546
5838
  // src/lib/sdk.ts
5547
5839
  var sdk_exports = {};
5548
5840
  __export(sdk_exports, {
5549
5841
  bootstrapDelegatedSession: () => bootstrapDelegatedSession,
5842
+ closeReplication: () => closeReplication,
5550
5843
  createSDKInstance: () => createSDKInstance,
5551
5844
  ensureAuthenticated: () => ensureAuthenticated,
5552
5845
  jwkHasPrivateParameter: () => jwkHasPrivateParameter,
5846
+ registerReplication: () => registerReplication,
5847
+ registeredReplications: () => registeredReplications,
5848
+ replicationForProfile: () => replicationForProfile,
5553
5849
  restoreProfileSessionNode: () => restoreProfileSessionNode,
5554
5850
  selectSignerJwk: () => selectSignerJwk,
5555
5851
  storedSessionVerifiesLocally: () => storedSessionVerifiesLocally
@@ -5664,10 +5960,21 @@ async function createSDKInstance(ctx, options) {
5664
5960
  );
5665
5961
  }
5666
5962
  if (profile?.authMethod === "local" && effectivePrivateKey) {
5963
+ const replication2 = createCliReplicationConfig(
5964
+ ctx.profile,
5965
+ profile?.replication,
5966
+ { debug: ctx.replicationDebug, quiet: ctx.quiet },
5967
+ ctx.replication
5968
+ );
5969
+ if (ctx.replication && !replication2) {
5970
+ process.stderr.write("[replication] Enabled, but this profile has no configured prefixes; reads use the network.\n");
5971
+ }
5667
5972
  const node2 = new TinyCloudNode({
5668
5973
  host: ctx.host,
5669
- privateKey: effectivePrivateKey
5974
+ privateKey: effectivePrivateKey,
5975
+ ...replication2 ? { replication: replication2 } : {}
5670
5976
  });
5977
+ if (node2.replication) registerReplication(ctx.profile, node2.replication);
5671
5978
  const restoredOwnSession = Boolean(session && session.delegationHeader && session.delegationCid && session.spaceId);
5672
5979
  if (restoredOwnSession) {
5673
5980
  await restoreProfileSession(node2, ctx.profile, profile, persistedSessionData(ctx.profile, profile, session, key));
@@ -5683,10 +5990,21 @@ async function createSDKInstance(ctx, options) {
5683
5990
  }
5684
5991
  return node2;
5685
5992
  }
5993
+ const replication = createCliReplicationConfig(
5994
+ ctx.profile,
5995
+ profile?.replication,
5996
+ { debug: ctx.replicationDebug, quiet: ctx.quiet },
5997
+ ctx.replication
5998
+ );
5999
+ if (ctx.replication && !replication) {
6000
+ process.stderr.write("[replication] Enabled, but this profile has no configured prefixes; reads use the network.\n");
6001
+ }
5686
6002
  const node = new TinyCloudNode({
5687
6003
  host: ctx.host,
5688
- privateKey: options?.privateKey
6004
+ privateKey: options?.privateKey,
6005
+ ...replication ? { replication } : {}
5689
6006
  });
6007
+ if (node.replication) registerReplication(ctx.profile, node.replication);
5690
6008
  if (options?.privateKey) {
5691
6009
  await node.signIn();
5692
6010
  } else if (session && session.delegationHeader && session.delegationCid && session.spaceId) {
@@ -5828,6 +6146,9 @@ var init_sdk = __esm({
5828
6146
  init_session_expired();
5829
6147
  init_warnings();
5830
6148
  init_delegation_expiry();
6149
+ init_replication_config();
6150
+ init_replication_registry();
6151
+ init_replication_registry();
5831
6152
  BOOTSTRAP_PROFILE_FIELDS = ["sessionDid", "spaceId"];
5832
6153
  }
5833
6154
  });
@@ -5968,14 +6289,14 @@ var init_host = __esm({
5968
6289
 
5969
6290
  // src/config/profiles.ts
5970
6291
  import { chmod as chmod2, lstat, readdir as readdir2, rm as rm2, rmdir } from "fs/promises";
5971
- import { join as join4 } from "path";
6292
+ import { join as join6 } from "path";
5972
6293
  import {
5973
- profilePath,
6294
+ profilePath as profilePath2,
5974
6295
  readSession,
5975
6296
  recordProfileDeletion,
5976
6297
  refuseWriteToDeletedProfile as refuseWriteToDeletedProfile2,
5977
6298
  removeSession,
5978
- withProfileLock as withProfileLock2,
6299
+ withProfileLock as withProfileLock3,
5979
6300
  writeSession
5980
6301
  } from "@tinycloud/operations/state";
5981
6302
  var ProfileManager;
@@ -5993,7 +6314,7 @@ var init_profiles = __esm({
5993
6314
  * it around a whole read-modify-write, not just each write.
5994
6315
  */
5995
6316
  static async withLock(name, action, options) {
5996
- return withProfileLock2(name, action, options);
6317
+ return withProfileLock3(name, action, options);
5997
6318
  }
5998
6319
  /**
5999
6320
  * Read-modify-write of profile.json under the profile lock, so concurrent
@@ -6019,7 +6340,7 @@ var init_profiles = __esm({
6019
6340
  /** Owner-only profile directory (0700), created or tightened before any write into it. */
6020
6341
  static async ensureProfileDir(name) {
6021
6342
  await _ProfileManager.ensureConfigDir();
6022
- const profileDir = join4(PROFILES_DIR, name);
6343
+ const profileDir = join6(PROFILES_DIR, name);
6023
6344
  await ensureDir(profileDir);
6024
6345
  await chmod2(profileDir, PRIVATE_DIR_MODE);
6025
6346
  return profileDir;
@@ -6048,8 +6369,8 @@ var init_profiles = __esm({
6048
6369
  * Throws CLIError if the profile doesn't exist.
6049
6370
  */
6050
6371
  static async getProfile(name) {
6051
- const profilePath2 = join4(PROFILES_DIR, name, "profile.json");
6052
- const profile = await readJson(profilePath2);
6372
+ const profilePath3 = join6(PROFILES_DIR, name, "profile.json");
6373
+ const profile = await readJson(profilePath3);
6053
6374
  if (!profile) {
6054
6375
  throw new CLIError(
6055
6376
  "PROFILE_NOT_FOUND",
@@ -6064,18 +6385,18 @@ var init_profiles = __esm({
6064
6385
  */
6065
6386
  static async setProfile(name, data) {
6066
6387
  await _ProfileManager.withLock(name, async () => {
6067
- await writeJson(join4(await _ProfileManager.ensureProfileDir(name), "profile.json"), data);
6388
+ await writeJson(join6(await _ProfileManager.ensureProfileDir(name), "profile.json"), data);
6068
6389
  });
6069
6390
  }
6070
6391
  /** Removes profile.json under the profile lock (rollback of a profile a failed login created). */
6071
6392
  static async removeProfileConfig(name) {
6072
- await _ProfileManager.withLock(name, () => rm2(join4(PROFILES_DIR, name, "profile.json"), { force: true }));
6393
+ await _ProfileManager.withLock(name, () => rm2(join6(PROFILES_DIR, name, "profile.json"), { force: true }));
6073
6394
  }
6074
6395
  /**
6075
6396
  * Returns true if a profile directory exists.
6076
6397
  */
6077
6398
  static async profileExists(name) {
6078
- return fileExists(join4(PROFILES_DIR, name, "profile.json"));
6399
+ return fileExists(join6(PROFILES_DIR, name, "profile.json"));
6079
6400
  }
6080
6401
  /**
6081
6402
  * Returns an array of profile directory names.
@@ -6099,7 +6420,7 @@ var init_profiles = __esm({
6099
6420
  static async deleteProfile(name) {
6100
6421
  let profileDir;
6101
6422
  try {
6102
- profileDir = profilePath(name);
6423
+ profileDir = profilePath2(name);
6103
6424
  } catch {
6104
6425
  throw new CLIError(
6105
6426
  "INVALID_PROFILE_NAME",
@@ -6124,11 +6445,11 @@ var init_profiles = __esm({
6124
6445
  return;
6125
6446
  }
6126
6447
  await _ProfileManager.withLock(name, async () => {
6127
- for (const file of ["session.json", "key.json"]) await rm2(join4(profileDir, file), { force: true });
6448
+ for (const file of ["session.json", "key.json"]) await rm2(join6(profileDir, file), { force: true });
6128
6449
  for (const entry of await readdir2(profileDir)) {
6129
- if (entry !== ".lock" && entry !== "profile.json") await rm2(join4(profileDir, entry), { recursive: true, force: true });
6450
+ if (entry !== ".lock" && entry !== "profile.json") await rm2(join6(profileDir, entry), { recursive: true, force: true });
6130
6451
  }
6131
- await rm2(join4(profileDir, "profile.json"), { force: true });
6452
+ await rm2(join6(profileDir, "profile.json"), { force: true });
6132
6453
  await recordProfileDeletion(name);
6133
6454
  });
6134
6455
  await rmdir(profileDir).catch((error) => {
@@ -6140,7 +6461,7 @@ var init_profiles = __esm({
6140
6461
  * Returns the parsed JWK for a profile, or null if no key exists.
6141
6462
  */
6142
6463
  static async getKey(name) {
6143
- return readJson(join4(PROFILES_DIR, name, "key.json"));
6464
+ return readJson(join6(PROFILES_DIR, name, "key.json"));
6144
6465
  }
6145
6466
  /**
6146
6467
  * Saves a JWK key under the profile lock (0600, in an owner-only profile
@@ -6150,12 +6471,12 @@ var init_profiles = __esm({
6150
6471
  static async setKey(name, jwk) {
6151
6472
  await _ProfileManager.withLock(name, async () => {
6152
6473
  await refuseWriteToDeletedProfile2(name);
6153
- await writeJson(join4(await _ProfileManager.ensureProfileDir(name), "key.json"), jwk);
6474
+ await writeJson(join6(await _ProfileManager.ensureProfileDir(name), "key.json"), jwk);
6154
6475
  });
6155
6476
  }
6156
6477
  /** Removes key.json under the profile lock (rollback of a key a failed login created). */
6157
6478
  static async removeKey(name) {
6158
- await _ProfileManager.withLock(name, () => rm2(join4(PROFILES_DIR, name, "key.json"), { force: true }));
6479
+ await _ProfileManager.withLock(name, () => rm2(join6(PROFILES_DIR, name, "key.json"), { force: true }));
6159
6480
  }
6160
6481
  // ── Session management ──────────────────────────────────────────────
6161
6482
  /**
@@ -6182,7 +6503,7 @@ var init_profiles = __esm({
6182
6503
  * created or tightened to 0700.
6183
6504
  */
6184
6505
  static async getCacheDir(name) {
6185
- const cacheDir = join4(await _ProfileManager.ensureProfileDir(name), "cache");
6506
+ const cacheDir = join6(await _ProfileManager.ensureProfileDir(name), "cache");
6186
6507
  await ensureDir(cacheDir);
6187
6508
  await chmod2(cacheDir, PRIVATE_DIR_MODE);
6188
6509
  return cacheDir;
@@ -6205,11 +6526,20 @@ var init_profiles = __esm({
6205
6526
  const config = await _ProfileManager.getConfig();
6206
6527
  const profile = options.profile ?? process.env.TC_PROFILE ?? config.defaultProfile ?? DEFAULT_PROFILE;
6207
6528
  let profileHost;
6529
+ let profileConfig;
6208
6530
  try {
6209
- const profileConfig = await _ProfileManager.getProfile(profile);
6531
+ profileConfig = await _ProfileManager.getProfile(profile);
6210
6532
  profileHost = profileConfig.host;
6211
6533
  } catch {
6212
6534
  }
6535
+ const envReplication = process.env.TC_REPLICATION?.toLowerCase();
6536
+ const envEnabled = envReplication === "1" || envReplication === "true" || envReplication === "on";
6537
+ const explicitEnvOff = envReplication === "0" || envReplication === "false" || envReplication === "off";
6538
+ if (envReplication !== void 0 && !envEnabled && !explicitEnvOff) {
6539
+ throw new CLIError("INVALID_ARGUMENT", "TC_REPLICATION must be one of 1, true, on, 0, false or off.", ExitCode.USAGE_ERROR);
6540
+ }
6541
+ const explicitOff = options.noReplication === true || options.replication === false;
6542
+ const replication = options.replication === true ? true : explicitOff ? false : envEnabled;
6213
6543
  const explicitHost = options.host ?? process.env.TC_HOST;
6214
6544
  let host = explicitHost ?? profileHost ?? DEFAULT_HOST;
6215
6545
  let localDiscovery;
@@ -6229,6 +6559,8 @@ var init_profiles = __esm({
6229
6559
  verbose: options.verbose ?? false,
6230
6560
  noCache: options.noCache ?? false,
6231
6561
  quiet: options.quiet ?? false,
6562
+ replication,
6563
+ replicationDebug: options.replicationDebug === true || process.env.TC_REPLICATION_DEBUG === "1",
6232
6564
  ...localDiscovery === void 0 ? {} : { localDiscovery }
6233
6565
  };
6234
6566
  }
@@ -6337,6 +6669,7 @@ function emitBanner(version2) {
6337
6669
  }
6338
6670
 
6339
6671
  // src/index.ts
6672
+ init_replication_registry();
6340
6673
  init_theme();
6341
6674
  init_formatter();
6342
6675
  init_profiles();
@@ -8059,8 +8392,8 @@ var BLAKE3 = class _BLAKE3 extends BLAKE2 {
8059
8392
  }
8060
8393
  _cloneInto(to) {
8061
8394
  to = super._cloneInto(to);
8062
- const { IV, flags, state, chunkPos, posOut, chunkOut, stack, chunksDone } = this;
8063
- to.state.set(state.slice());
8395
+ const { IV, flags, state: state2, chunkPos, posOut, chunkOut, stack, chunksDone } = this;
8396
+ to.state.set(state2.slice());
8064
8397
  to.stack = stack.map((i) => Uint32Array.from(i));
8065
8398
  to.IV.set(IV);
8066
8399
  to.flags = flags;
@@ -15280,12 +15613,12 @@ async function notifyShare(input) {
15280
15613
  if (input.signal?.aborted) throw new ShareNotifyError("share delivery was cancelled");
15281
15614
  attempts += 1;
15282
15615
  try {
15283
- const state = await input.adapter.deliver({ shareId: input.shareId, recipient, idempotencyKey, ...input.record === void 0 ? {} : { record: input.record }, ...input.signal === void 0 ? {} : { signal: input.signal } });
15616
+ const state2 = await input.adapter.deliver({ shareId: input.shareId, recipient, idempotencyKey, ...input.record === void 0 ? {} : { record: input.record }, ...input.signal === void 0 ? {} : { signal: input.signal } });
15284
15617
  return {
15285
15618
  protocol: "tinycloud-share",
15286
15619
  version: 1,
15287
15620
  shareId: input.shareId,
15288
- state: state === "delivered" && input.record?.deliveredRecipients?.includes(recipient) ? "already-delivered" : state,
15621
+ state: state2 === "delivered" && input.record?.deliveredRecipients?.includes(recipient) ? "already-delivered" : state2,
15289
15622
  idempotencyKey,
15290
15623
  attempts
15291
15624
  };
@@ -16982,7 +17315,7 @@ function receiveJson(result, path) {
16982
17315
  import { constants } from "fs";
16983
17316
  import { lstat as lstat2, mkdir as mkdir2, mkdtemp, open as open2, readFile as readFile3, realpath, stat as stat2, link, rename as rename2, rm as rm3, unlink } from "fs/promises";
16984
17317
  import { randomBytes as randomBytes2 } from "crypto";
16985
- import { basename as basename2, join as join5, resolve, sep } from "path";
17318
+ import { basename as basename2, join as join7, resolve, sep } from "path";
16986
17319
  init_errors();
16987
17320
  var MAX_SHARE_STDIN_BYTES = 100 * 1024 * 1024;
16988
17321
  var MAX_SHARE_URL_BYTES = 64 * 1024;
@@ -17036,7 +17369,7 @@ async function assertDirectory(path) {
17036
17369
  const segments = absolute.split(sep).filter(Boolean);
17037
17370
  let current = absolute.startsWith(sep) ? sep : "";
17038
17371
  for (const segment of segments) {
17039
- current = current === sep ? join5(current, segment) : join5(current, segment);
17372
+ current = current === sep ? join7(current, segment) : join7(current, segment);
17040
17373
  try {
17041
17374
  const info = await lstat2(current);
17042
17375
  if (info.isSymbolicLink()) {
@@ -17058,17 +17391,17 @@ async function writeShareOutput(directory, filename, bytes, force) {
17058
17391
  const safeName = safeFilename(filename);
17059
17392
  const directoryHandle = await open2(outputDirectory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0));
17060
17393
  const stableDirectory = await realpath(outputDirectory);
17061
- const outputPath = join5(stableDirectory, safeName);
17394
+ const outputPath = join7(stableDirectory, safeName);
17062
17395
  const directoryIdentity = await directoryHandle.stat();
17063
17396
  const assertStableDirectory = async () => {
17064
17397
  const current = await stat2(stableDirectory);
17065
17398
  if (current.dev !== directoryIdentity.dev || current.ino !== directoryIdentity.ino) throw new Error("OUTPUT_EXISTS");
17066
17399
  };
17067
17400
  await assertStableDirectory();
17068
- const stagingDirectory = await mkdtemp(join5(stableDirectory, ".tinycloud-share-stage-"));
17401
+ const stagingDirectory = await mkdtemp(join7(stableDirectory, ".tinycloud-share-stage-"));
17069
17402
  const stagingInfo = await lstat2(stagingDirectory);
17070
17403
  if (!stagingInfo.isDirectory() || (stagingInfo.mode & 511) !== 448) throw new Error("OUTPUT_EXISTS");
17071
- const stagingPath = join5(stagingDirectory, `.tinycloud-share-${randomBytes2(16).toString("hex")}.tmp`);
17404
+ const stagingPath = join7(stagingDirectory, `.tinycloud-share-${randomBytes2(16).toString("hex")}.tmp`);
17072
17405
  let temporaryPath;
17073
17406
  let handle;
17074
17407
  try {
@@ -17105,7 +17438,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
17105
17438
  await rm3(stagingDirectory, { recursive: true, force: true });
17106
17439
  await directoryHandle.close();
17107
17440
  }
17108
- return join5(outputDirectory, safeName);
17441
+ return join7(outputDirectory, safeName);
17109
17442
  }
17110
17443
 
17111
17444
  // src/share/errors.ts
@@ -17391,7 +17724,7 @@ function registerShareCommand(program2) {
17391
17724
  if (json) writeJson2({ ...redactPublishedShare(result), expiryClamped: result.metadata.expiryClamped === true, ...notification === void 0 ? {} : { notification } });
17392
17725
  else publishHuman(result);
17393
17726
  } catch (error) {
17394
- handleError(shareCliError(error));
17727
+ return handleError(shareCliError(error));
17395
17728
  }
17396
17729
  });
17397
17730
  share.command("inspect [url]").description("Verify a share link and print safe metadata").option("--stdin", "Read the complete URL from stdin").option("--json", "Print versioned redacted JSON").option("--viewer-origin <origin>", "Require this canonical Share origin", SHARE_ORIGIN).action(async (url, options, command) => {
@@ -17401,7 +17734,7 @@ function registerShareCommand(program2) {
17401
17734
  if (json) writeJson2(result);
17402
17735
  else inspectHuman(result);
17403
17736
  } catch (error) {
17404
- handleError(shareCliError(error));
17737
+ return handleError(shareCliError(error));
17405
17738
  }
17406
17739
  });
17407
17740
  share.command("receive [url]").description("Verify and receive a share link").option("--stdin", "Read the complete URL from stdin").option("--output <directory>", "Create the file in this directory").option("--stdout", "Write verified plaintext bytes to stdout").option("--force", "Allow replacing an existing non-symlink output").option("--max-bytes <bytes>", "Bound received content bytes").option("--json", "Print versioned redacted JSON").option("--viewer-origin <origin>", "Require this canonical Share origin", SHARE_ORIGIN).action(async (url, options, command) => {
@@ -17449,7 +17782,7 @@ function registerShareCommand(program2) {
17449
17782
  if (json) receiveJson(result, output);
17450
17783
  else receiveHuman(output);
17451
17784
  } catch (error) {
17452
- handleError(shareCliError(error));
17785
+ return handleError(shareCliError(error));
17453
17786
  }
17454
17787
  });
17455
17788
  share.command("list").description("List encrypted sender history without complete bearer URLs").option("--json", "Print versioned redacted JSON").action(async (options, command) => {
@@ -17460,7 +17793,7 @@ function registerShareCommand(program2) {
17460
17793
  if (json) writeJson2({ protocol: "tinycloud-share", version: 1, shares: result });
17461
17794
  else process.stdout.write(result.map((item) => `${item.shareId} ${item.target} ${item.expiresAt}`).join("\n") + (result.length ? "\n" : ""));
17462
17795
  } catch (error) {
17463
- handleError(shareCliError(error));
17796
+ return handleError(shareCliError(error));
17464
17797
  }
17465
17798
  });
17466
17799
  share.command("show <id>").description("Show one redacted sender-history record").option("--reveal-link", "Explicitly include the complete link").option("--json", "Print versioned redacted JSON").action(async (id, options, command) => {
@@ -17472,7 +17805,7 @@ function registerShareCommand(program2) {
17472
17805
  if (json) writeJson2({ protocol: "tinycloud-share", version: 1, share: result });
17473
17806
  else writeJson2(result);
17474
17807
  } catch (error) {
17475
- handleError(shareCliError(error));
17808
+ return handleError(shareCliError(error));
17476
17809
  }
17477
17810
  });
17478
17811
  share.command("notify <id>").description("Retry idempotent delivery within 5 minutes of publication; after that publish a new share").requiredOption("--to <address>", "Recipient email").option("--json", "Print versioned JSON").action(async (id, options, command) => {
@@ -17499,7 +17832,7 @@ function registerShareCommand(program2) {
17499
17832
  if (result.reason === "delivery-window-expired") process.stderr.write(NOTIFY_WINDOW_MESSAGE);
17500
17833
  if (result.state === "partial-failure") process.exitCode = 9;
17501
17834
  } catch (error) {
17502
- handleError(shareCliError(error, "notify"));
17835
+ return handleError(shareCliError(error, "notify"));
17503
17836
  }
17504
17837
  });
17505
17838
  share.command("revoke <id>").description("Revoke addressed shares; report bearer retention honestly").option("--ancestor", "Revoke the owner delegation ancestry").option("--json", "Print versioned JSON").action(async (id, options, command) => {
@@ -17516,7 +17849,7 @@ function registerShareCommand(program2) {
17516
17849
  else process.stdout.write(`${result.state}
17517
17850
  `);
17518
17851
  } catch (error) {
17519
- handleError(shareCliError(error));
17852
+ return handleError(shareCliError(error));
17520
17853
  }
17521
17854
  });
17522
17855
  }
@@ -17527,7 +17860,7 @@ init_profiles();
17527
17860
  init_constants();
17528
17861
  init_session_expired();
17529
17862
  import { readFile as readFile4 } from "fs/promises";
17530
- import { join as join6 } from "path";
17863
+ import { join as join8 } from "path";
17531
17864
  import { createHash } from "crypto";
17532
17865
  import { writeJsonAtomic as writeJsonAtomic2 } from "@tinycloud/operations/state";
17533
17866
  import { LocationRecordValidationError, LocationRegistryHttpError, revokePolicyRootV3 } from "@tinycloud/sdk-core";
@@ -17632,8 +17965,8 @@ function createEncryptedProfileHistory(profileName, sessionSigner) {
17632
17965
  const observedProfiles = /* @__PURE__ */ new Set();
17633
17966
  let preparedKeys;
17634
17967
  let preparedKey;
17635
- const path = async (profile) => join6(await ProfileManager.getCacheDir(profile), "share-history-v2.json");
17636
- const legacyPath = async (profile) => join6(await ProfileManager.getCacheDir(profile), "share-history-v1.bin");
17968
+ const path = async (profile) => join8(await ProfileManager.getCacheDir(profile), "share-history-v2.json");
17969
+ const legacyPath = async (profile) => join8(await ProfileManager.getCacheDir(profile), "share-history-v1.bin");
17637
17970
  const b64 = (value) => Buffer.from(value).toString("base64url");
17638
17971
  const unb64 = (value) => {
17639
17972
  if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("share history is unavailable");
@@ -18197,7 +18530,7 @@ function selectedShareProfile() {
18197
18530
  }
18198
18531
  return process.env.TC_PROFILE;
18199
18532
  }
18200
- program.name("tc").description("TinyCloud CLI \u2014 self-sovereign storage from the terminal").version(version).option("-p, --profile <name>", "Profile to use").option("-H, --host <url>", "TinyCloud node URL").option("-v, --verbose", "Enable verbose output").option("--no-cache", "Disable caching").option("-q, --quiet", "Suppress non-essential output").option("--json", "Force JSON output");
18533
+ program.name("tc").description("TinyCloud CLI \u2014 self-sovereign storage from the terminal").version(version).option("-p, --profile <name>", "Profile to use").option("-H, --host <url>", "TinyCloud node URL").option("-v, --verbose", "Enable verbose output").option("--no-cache", "Disable caching").option("-q, --quiet", "Suppress non-essential output").option("--json", "Force JSON output").option("--replication", "Enable read-through replication for this invocation").option("--no-replication", "Disable read-through replication for this invocation").option("--replication-debug", "Write replication diagnostics to stderr");
18201
18534
  program.hook("preAction", async (thisCommand) => {
18202
18535
  const opts = thisCommand.optsWithGlobals();
18203
18536
  setJsonOutputRequested(opts.json === true);
@@ -18248,7 +18581,7 @@ function firstCommandToken(values) {
18248
18581
  continue;
18249
18582
  }
18250
18583
  if (value.startsWith("--profile=") || value.startsWith("--host=")) continue;
18251
- if (value === "--verbose" || value === "--no-cache" || value === "-q" || value === "--quiet" || value === "--json") continue;
18584
+ if (value === "--verbose" || value === "--no-cache" || value === "-q" || value === "--quiet" || value === "--json" || value === "--replication" || value === "--no-replication" || value === "--replication-debug") continue;
18252
18585
  if (value.startsWith("-")) continue;
18253
18586
  return value;
18254
18587
  }
@@ -18283,8 +18616,18 @@ ${theme.muted("Repo:")} ${theme.accent("https://github.com/tinycloudlabs/web-sdk
18283
18616
  });
18284
18617
  try {
18285
18618
  await program.parseAsync(process.argv);
18619
+ if (await closeReplication()) {
18620
+ process.exitCode = 0;
18621
+ const stdoutFlushed = Promise.withResolvers();
18622
+ process.stdout.write("", () => stdoutFlushed.resolve());
18623
+ await stdoutFlushed.promise;
18624
+ const stderrFlushed = Promise.withResolvers();
18625
+ process.stderr.write("", () => stderrFlushed.resolve());
18626
+ await stderrFlushed.promise;
18627
+ process.exit();
18628
+ }
18286
18629
  } catch (error) {
18287
- handleError(error);
18630
+ await handleError(error);
18288
18631
  }
18289
18632
  /*! Bundled license information:
18290
18633