@narumitw/pi-sync 0.49.14 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +134 -241
  2. package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
  3. package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
  4. package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
  5. package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
  6. package/dist/chunks/chunk-6UHKGALE.ts +134 -0
  7. package/dist/chunks/chunk-6UHKGALE.ts.map +7 -0
  8. package/dist/chunks/{chunk-RGJ6SOYR.ts → chunk-AUSDLD6F.ts} +4 -4
  9. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  10. package/dist/chunks/{chunk-3NDD5QPM.ts → chunk-AXXS2BPA.ts} +9 -9
  11. package/dist/chunks/chunk-AXXS2BPA.ts.map +7 -0
  12. package/dist/chunks/{chunk-APZVBSM6.ts → chunk-C7XY5NF5.ts} +214 -365
  13. package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
  14. package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
  15. package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
  16. package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
  17. package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
  18. package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
  19. package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
  20. package/dist/chunks/{chunk-LJDSHNS3.ts → chunk-GWMHAQG4.ts} +4 -4
  21. package/dist/chunks/chunk-GWMHAQG4.ts.map +7 -0
  22. package/dist/chunks/{chunk-FR6CVXAL.ts → chunk-HUDQATCL.ts} +25 -25
  23. package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
  24. package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
  25. package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
  26. package/dist/chunks/chunk-JSYMGJQQ.ts +51 -0
  27. package/dist/chunks/chunk-JSYMGJQQ.ts.map +7 -0
  28. package/dist/chunks/chunk-L7TBO32M.ts +25 -0
  29. package/dist/chunks/chunk-L7TBO32M.ts.map +7 -0
  30. package/dist/chunks/{chunk-4F3W4CSC.ts → chunk-LCRI3ABW.ts} +22 -8
  31. package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
  32. package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
  33. package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
  34. package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
  35. package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
  36. package/dist/chunks/{chunk-EWX3TPLQ.ts → chunk-OEX4ABY6.ts} +39 -37
  37. package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
  38. package/dist/chunks/chunk-OUHGBFYF.ts +1255 -0
  39. package/dist/chunks/chunk-OUHGBFYF.ts.map +7 -0
  40. package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
  41. package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
  42. package/dist/chunks/{chunk-BHJUISB4.ts → chunk-SL64RLXR.ts} +8 -6
  43. package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
  44. package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
  45. package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
  46. package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
  47. package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
  48. package/dist/chunks/chunk-THMF4BVL.ts +18 -0
  49. package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
  50. package/dist/chunks/{chunk-LONPFFNP.ts → chunk-X5OM6GXT.ts} +9 -7
  51. package/dist/chunks/chunk-X5OM6GXT.ts.map +7 -0
  52. package/dist/chunks/chunk-XBX4HBLG.ts +57 -0
  53. package/dist/chunks/chunk-XBX4HBLG.ts.map +7 -0
  54. package/dist/chunks/{chunk-5WJNWFTG.ts → chunk-YKJHPTGO.ts} +23 -55
  55. package/dist/chunks/chunk-YKJHPTGO.ts.map +7 -0
  56. package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
  57. package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
  58. package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
  59. package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
  60. package/dist/chunks/{file-selection-4UABOP44.ts → file-selection-FTTKAOYX.ts} +10 -7
  61. package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
  62. package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-OQW46G2X.ts} +42 -17
  63. package/dist/chunks/git-backend-OQW46G2X.ts.map +7 -0
  64. package/dist/chunks/manager-result-dispatcher-U23ODD32.ts +22 -0
  65. package/dist/chunks/manager-ui-ABATHC36.ts +1680 -0
  66. package/dist/chunks/manager-ui-ABATHC36.ts.map +7 -0
  67. package/dist/chunks/remote-selection-ui-ZE3TY67S.ts +21 -0
  68. package/dist/chunks/{s3-backend-3QZGO2EF.ts → s3-backend-3P33LQHU.ts} +96 -18
  69. package/dist/chunks/s3-backend-3P33LQHU.ts.map +7 -0
  70. package/dist/chunks/{setup-switch-X3W3FALZ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
  71. package/dist/chunks/setup-wizard-VB3VDOV5.ts +89 -0
  72. package/dist/chunks/setup-wizard-VB3VDOV5.ts.map +7 -0
  73. package/dist/chunks/{snapshot-WBSV4MQG.ts → snapshot-HKEWCVK6.ts} +7 -6
  74. package/dist/chunks/sync-inspection-FEP4L5TR.ts +20 -0
  75. package/dist/chunks/{sync-operations-MPAFVVHA.ts → sync-operations-ONJ27QRL.ts} +381 -299
  76. package/dist/chunks/sync-operations-ONJ27QRL.ts.map +7 -0
  77. package/dist/chunks/{sync-state-36H7ULG2.ts → sync-state-Q2FTXTW6.ts} +6 -6
  78. package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
  79. package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
  80. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
  81. package/dist/index.ts +477 -342
  82. package/dist/index.ts.map +4 -4
  83. package/docs/settings.md +211 -0
  84. package/package.json +6 -8
  85. package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
  86. package/src/backends/backend-identity.ts +40 -0
  87. package/src/backends/backend-types.ts +81 -0
  88. package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
  89. package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
  90. package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
  91. package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
  92. package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
  93. package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
  94. package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
  95. package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
  96. package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
  97. package/src/commands/command-execution.ts +330 -0
  98. package/src/commands/command-handler.ts +201 -0
  99. package/src/commands/command-types.ts +18 -0
  100. package/src/{command.ts → commands/command.ts} +2 -1
  101. package/src/commands/setup-completions.ts +6 -0
  102. package/src/settings/config.ts +238 -0
  103. package/src/{settings-management.ts → settings/settings-management.ts} +40 -14
  104. package/src/settings/settings-store.ts +109 -0
  105. package/src/settings/settings-types.ts +128 -0
  106. package/src/settings/settings-validation.ts +443 -0
  107. package/src/snapshot/session-paths.ts +84 -0
  108. package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
  109. package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
  110. package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
  111. package/src/snapshot/snapshot-types.ts +38 -0
  112. package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
  113. package/src/state/json-file.ts +20 -0
  114. package/src/{lock.ts → state/lock.ts} +3 -2
  115. package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
  116. package/src/state/state-types.ts +20 -0
  117. package/src/state/sync-state-store.ts +62 -0
  118. package/src/sync/automatic-sync.ts +103 -0
  119. package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
  120. package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
  121. package/src/sync/signals.ts +10 -0
  122. package/src/sync/startup-check.ts +128 -0
  123. package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
  124. package/src/sync/sync-error-guidance.ts +53 -0
  125. package/src/sync/sync-inspection.ts +51 -0
  126. package/src/sync/sync-loaders.ts +54 -0
  127. package/src/{sync-operations.ts → sync/sync-mutations.ts} +41 -330
  128. package/src/sync/sync-operations.ts +12 -0
  129. package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
  130. package/src/sync/sync-queries.ts +235 -0
  131. package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
  132. package/src/sync-extension.ts +64 -708
  133. package/src/sync.ts +8 -8
  134. package/src/types.ts +47 -284
  135. package/src/ui/automatic-sync-summary.ts +6 -0
  136. package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
  137. package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
  138. package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
  139. package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
  140. package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
  141. package/src/ui/manager-ui.ts +386 -0
  142. package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
  143. package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
  144. package/src/{settings-ui.ts → ui/settings-ui.ts} +42 -11
  145. package/src/ui/setup/git-ui.ts +293 -0
  146. package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
  147. package/src/ui/setup/s3-ui.ts +217 -0
  148. package/src/ui/setup/setup-actions.ts +127 -0
  149. package/src/ui/setup/setup-location-ui.ts +167 -0
  150. package/src/ui/setup/setup-name-ui.ts +34 -0
  151. package/src/ui/setup/setup-prompts.ts +92 -0
  152. package/src/ui/setup/setup-review.ts +70 -0
  153. package/src/ui/setup/setup-switcher.ts +181 -0
  154. package/src/ui/setup/setup-wizard.ts +39 -0
  155. package/src/ui/setup/text-input.ts +78 -0
  156. package/src/ui/setup/webdav-ui.ts +330 -0
  157. package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
  158. package/src/{sync-attention.ts → ui/sync-attention.ts} +75 -6
  159. package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
  160. package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
  161. package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
  162. package/src/ui/terminal-text.ts +4 -0
  163. package/dist/chunks/chunk-3NDD5QPM.ts.map +0 -7
  164. package/dist/chunks/chunk-4F3W4CSC.ts.map +0 -7
  165. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  166. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  167. package/dist/chunks/chunk-5WJNWFTG.ts.map +0 -7
  168. package/dist/chunks/chunk-APZVBSM6.ts.map +0 -7
  169. package/dist/chunks/chunk-BHJUISB4.ts.map +0 -7
  170. package/dist/chunks/chunk-EWX3TPLQ.ts.map +0 -7
  171. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  172. package/dist/chunks/chunk-FR6CVXAL.ts.map +0 -7
  173. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  174. package/dist/chunks/chunk-JNOOSPEW.ts +0 -82
  175. package/dist/chunks/chunk-JNOOSPEW.ts.map +0 -7
  176. package/dist/chunks/chunk-LJDSHNS3.ts.map +0 -7
  177. package/dist/chunks/chunk-LONPFFNP.ts.map +0 -7
  178. package/dist/chunks/chunk-RGJ6SOYR.ts.map +0 -7
  179. package/dist/chunks/chunk-W64NEHXT.ts +0 -167
  180. package/dist/chunks/chunk-W64NEHXT.ts.map +0 -7
  181. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  182. package/dist/chunks/file-selection-4UABOP44.ts.map +0 -7
  183. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  184. package/dist/chunks/manager-result-dispatcher-RNRQA3PW.ts +0 -18
  185. package/dist/chunks/manager-ui-QO2P4V3Z.ts +0 -2757
  186. package/dist/chunks/manager-ui-QO2P4V3Z.ts.map +0 -7
  187. package/dist/chunks/remote-selection-ui-LHSAGKGA.ts +0 -17
  188. package/dist/chunks/s3-backend-3QZGO2EF.ts.map +0 -7
  189. package/dist/chunks/sync-operations-MPAFVVHA.ts.map +0 -7
  190. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  191. package/src/config.ts +0 -910
  192. package/src/git-ui.ts +0 -307
  193. package/src/manager-helpers.ts +0 -64
  194. package/src/manager-ui.ts +0 -997
  195. package/src/webdav-ui.ts +0 -421
  196. /package/dist/chunks/{manager-result-dispatcher-RNRQA3PW.ts.map → manager-result-dispatcher-U23ODD32.ts.map} +0 -0
  197. /package/dist/chunks/{remote-selection-ui-LHSAGKGA.ts.map → remote-selection-ui-ZE3TY67S.ts.map} +0 -0
  198. /package/dist/chunks/{setup-switch-X3W3FALZ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  199. /package/dist/chunks/{snapshot-WBSV4MQG.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  200. /package/dist/chunks/{sync-state-36H7ULG2.ts.map → sync-inspection-FEP4L5TR.ts.map} +0 -0
  201. /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
  202. /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
  203. /package/src/{config-file.ts → settings/config-file.ts} +0 -0
  204. /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
  205. /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
  206. /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
  207. /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
  208. /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
  209. /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
@@ -0,0 +1,81 @@
1
+ import type { SnapshotSelection } from "../snapshot/snapshot-types.js";
2
+ /** Backend-only resolved S3 connection fields. */
3
+ export interface ResolvedS3StorageProfile {
4
+ kind: "r2" | "s3-compatible";
5
+ endpoint: string;
6
+ region: string;
7
+ accessKeyId: string;
8
+ secretAccessKey: string;
9
+ sessionToken?: string;
10
+ }
11
+
12
+ /** Backend-only coordinates. `prefix` is the complete reviewed v3 storage path. */
13
+ export interface ResolvedS3Destination {
14
+ bucket: string;
15
+ prefix: string;
16
+ /** Snapshot/wire identity; never derived from the local setup name. */
17
+ namespace: string;
18
+ }
19
+
20
+ export interface ResolvedS3Backend {
21
+ type: "s3";
22
+ profile: ResolvedS3StorageProfile;
23
+ destination: ResolvedS3Destination;
24
+ }
25
+
26
+ export interface ResolvedWebDavStorageProfile {
27
+ kind: "webdav";
28
+ url: string;
29
+ username: string;
30
+ password: string;
31
+ }
32
+
33
+ /** Backend-only coordinates. `path` is the complete reviewed v3 storage path. */
34
+ export interface ResolvedWebDavDestination {
35
+ path: string;
36
+ namespace: string;
37
+ }
38
+
39
+ export interface ResolvedWebDavBackend {
40
+ type: "webdav";
41
+ profile: ResolvedWebDavStorageProfile;
42
+ destination: ResolvedWebDavDestination;
43
+ }
44
+
45
+ export interface ResolvedGitStorageProfile {
46
+ kind: "git";
47
+ remote: string;
48
+ }
49
+
50
+ /** Backend-only coordinates. `directory` is the complete reviewed v3 storage path. */
51
+ export interface ResolvedGitDestination {
52
+ branch: string;
53
+ directory: string;
54
+ namespace: string;
55
+ }
56
+
57
+ export interface ResolvedGitBackend {
58
+ type: "git";
59
+ profile: ResolvedGitStorageProfile;
60
+ destination: ResolvedGitDestination;
61
+ }
62
+
63
+ export type ResolvedSyncBackend = ResolvedS3Backend | ResolvedWebDavBackend | ResolvedGitBackend;
64
+
65
+ export interface LatestPointer {
66
+ version: number;
67
+ profile: string;
68
+ snapshot: string;
69
+ sha256: string;
70
+ createdAt: string;
71
+ machine: string;
72
+ syncSessions?: boolean;
73
+ /** Lightweight projection; the immutable snapshot remains authoritative. */
74
+ selection?: SnapshotSelection;
75
+ }
76
+
77
+ export interface RemoteObject<T> {
78
+ value?: T;
79
+ etag?: string;
80
+ missing: boolean;
81
+ }
@@ -1,6 +1,22 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { posixJoin } from "../../paths.js";
5
+ import type { Snapshot, SnapshotFile } from "../../snapshot/snapshot-types.js";
6
+ import { stateDir } from "../../state/state-directory.js";
7
+ import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
8
+ import type { ResolvedGitBackend } from "../backend-types.js";
9
+ import {
10
+ type BackendDiagnostic,
11
+ type ExpectedRemoteHead,
12
+ type PublishSnapshotOptions,
13
+ type PublishSnapshotResult,
14
+ type RemoteHead,
15
+ type RemoteHistoryEntry,
16
+ type SyncBackend,
17
+ SyncBackendConflictError,
18
+ SyncBackendPublicationOutcomeUnknownError,
19
+ } from "../sync-backend.js";
4
20
  import {
5
21
  normalizeGitBranch,
6
22
  normalizeGitDirectory,
@@ -21,20 +37,6 @@ import {
21
37
  validateGitPublicationTree,
22
38
  validateGitSnapshot,
23
39
  } from "./git-storage.js";
24
- import { posixJoin } from "./paths.js";
25
- import { stateDir } from "./state-directory.js";
26
- import {
27
- type BackendDiagnostic,
28
- type ExpectedRemoteHead,
29
- type PublishSnapshotOptions,
30
- type PublishSnapshotResult,
31
- type RemoteHead,
32
- type RemoteHistoryEntry,
33
- type SyncBackend,
34
- SyncBackendConflictError,
35
- SyncBackendPublicationOutcomeUnknownError,
36
- } from "./sync-backend.js";
37
- import type { ResolvedGitBackend, Snapshot, SnapshotFile } from "./types.js";
38
40
 
39
41
  const COMMAND_TIMEOUT_MS = 30_000;
40
42
  const POST_COMMIT_TIMEOUT_MS = 45_000;
@@ -74,7 +76,7 @@ export class GitSyncBackend implements SyncBackend {
74
76
  if (!this.allowLocalRemotes) assertProductionRemote(config.profile.remote);
75
77
  this.identity = gitBackendIdentity(config);
76
78
  this.destination = gitDestination(config);
77
- this.cacheRoot = options.cacheRoot ?? path.join(stateDir(), "git");
79
+ this.cacheRoot = path.resolve(options.cacheRoot ?? path.join(stateDir(), "git"));
78
80
  this.cacheDir = path.join(this.cacheRoot, this.identity.slice("git:".length), "repository.git");
79
81
  this.commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
80
82
  this.postCommitTimeoutMs = options.postCommitTimeoutMs ?? POST_COMMIT_TIMEOUT_MS;
@@ -268,7 +270,14 @@ export class GitSyncBackend implements SyncBackend {
268
270
  }
269
271
 
270
272
  async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
271
- const diagnostics: BackendDiagnostic[] = [];
273
+ const diagnostics: BackendDiagnostic[] = [
274
+ {
275
+ key: "git-scope",
276
+ level: "info",
277
+ message:
278
+ "git check scope: read remote snapshots and inspect the local cache. Write access is not tested.",
279
+ },
280
+ ];
272
281
  try {
273
282
  const version = await runGit(["--version"], {
274
283
  signal,
@@ -284,7 +293,13 @@ export class GitSyncBackend implements SyncBackend {
284
293
  : `${versionText || "unknown Git version"}; pi-sync requires Git 2.30 or newer`,
285
294
  });
286
295
  } catch (error) {
287
- return [{ key: "git-version", level: "error", message: this.safeError(error) }];
296
+ return [
297
+ {
298
+ key: "git-version",
299
+ level: "error",
300
+ message: syncErrorGuidance(new Error(this.safeError(error))),
301
+ },
302
+ ];
288
303
  }
289
304
  try {
290
305
  const head = await this.readHead(signal);
@@ -305,7 +320,7 @@ export class GitSyncBackend implements SyncBackend {
305
320
  diagnostics.push({
306
321
  key: "git-remote",
307
322
  level: "error",
308
- message: `git remote: ${this.safeError(error)}`,
323
+ message: `git remote: ${syncErrorGuidance(new Error(this.safeError(error)))}`,
309
324
  });
310
325
  }
311
326
  return diagnostics;
@@ -547,12 +562,14 @@ export class GitSyncBackend implements SyncBackend {
547
562
  try {
548
563
  recreate = !(await this.cacheUsesSha1(signal));
549
564
  } catch {
565
+ throwIfAborted(signal);
550
566
  recreate = true;
551
567
  }
552
568
  }
553
569
  } catch (error) {
554
570
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
555
571
  }
572
+ throwIfAborted(signal);
556
573
  if (recreate) await fs.rm(this.cacheDir, { recursive: true, force: true });
557
574
  try {
558
575
  await fs.access(this.cacheDir);
@@ -604,7 +621,7 @@ export class GitSyncBackend implements SyncBackend {
604
621
  }
605
622
 
606
623
  private publicationPath() {
607
- return this.config.destination.directory;
624
+ return this.config.destination.directory === "./" ? "" : this.config.destination.directory;
608
625
  }
609
626
 
610
627
  private manifestPath() {
@@ -766,10 +783,12 @@ async function withGitCacheMutation<T>(
766
783
  signal?: AbortSignal,
767
784
  ): Promise<T> {
768
785
  const previous = gitCacheMutationQueues.get(cacheDir) ?? Promise.resolve();
786
+ let started = false;
769
787
  const operation = previous
770
788
  .catch(() => undefined)
771
789
  .then(() => {
772
790
  throwIfAborted(signal);
791
+ started = true;
773
792
  return run();
774
793
  });
775
794
  const tail = operation.then(
@@ -791,6 +810,11 @@ async function withGitCacheMutation<T>(
791
810
  if (signal.aborted) onAbort();
792
811
  try {
793
812
  return await Promise.race([operation, aborted]);
813
+ } catch (error) {
814
+ // A queued job can cancel immediately, but a running job owns a child/cache
815
+ // mutation. Drain its bounded cleanup before releasing outer sync guards.
816
+ if (started) await tail;
817
+ throw error;
794
818
  } finally {
795
819
  signal.removeEventListener("abort", onAbort);
796
820
  }
@@ -88,6 +88,7 @@ export function normalizeGitBranch(value: string | undefined) {
88
88
  }
89
89
 
90
90
  export function normalizeGitDirectory(value: string | undefined) {
91
+ if (value?.trim() === "." || value?.trim() === "./") return "./";
91
92
  const directory = trimSlashes(normalizeOptionalString(value) ?? DEFAULT_GIT_DIRECTORY);
92
93
  if (
93
94
  !directory ||
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { portableSnapshotSelection, snapshotSelectionInclude } from "./sync-policy.js";
3
- import type { Snapshot, SnapshotSelection } from "./types.js";
2
+ import type { Snapshot, SnapshotSelection } from "../../snapshot/snapshot-types.js";
3
+ import { portableSnapshotSelection, snapshotSelectionInclude } from "../../sync/sync-policy.js";
4
4
 
5
5
  export const GIT_MANIFEST_VERSION = 2;
6
6
  export const MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
@@ -1,8 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { sessionTokenWarnings } from "./config.js";
3
- import { encodeKey, posixJoin } from "./paths.js";
4
- import { S3Client, S3ObjectAlreadyExistsError } from "./s3-client.js";
5
- import { decodeSnapshot, encodeSnapshot } from "./snapshot-codec.js";
2
+ import { encodeKey, posixJoin } from "../../paths.js";
3
+ import { sessionTokenWarnings } from "../../settings/config.js";
4
+ import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
5
+ import type { Snapshot } from "../../snapshot/snapshot-types.js";
6
+ import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
7
+ import { portableSnapshotSelection } from "../../sync/sync-policy.js";
8
+ import type { LatestPointer, RemoteObject, ResolvedS3Backend } from "../backend-types.js";
6
9
  import {
7
10
  type BackendDiagnostic,
8
11
  type ExpectedRemoteHead,
@@ -13,9 +16,8 @@ import {
13
16
  type SyncBackend,
14
17
  SyncBackendConflictError,
15
18
  SyncBackendPublicationOutcomeUnknownError,
16
- } from "./sync-backend.js";
17
- import { portableSnapshotSelection } from "./sync-policy.js";
18
- import type { LatestPointer, RemoteObject, ResolvedS3Backend, Snapshot } from "./types.js";
19
+ } from "../sync-backend.js";
20
+ import { S3Client, S3ObjectAlreadyExistsError } from "./s3-client.js";
19
21
 
20
22
  const VERSION = 1;
21
23
  const POST_COMMIT_TIMEOUT_MS = 30_000;
@@ -189,7 +191,7 @@ export class S3SyncBackend implements SyncBackend {
189
191
 
190
192
  async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
191
193
  throwIfAborted(signal);
192
- return [
194
+ const diagnostics: BackendDiagnostic[] = [
193
195
  {
194
196
  key: "s3-config",
195
197
  level: "info",
@@ -201,6 +203,39 @@ export class S3SyncBackend implements SyncBackend {
201
203
  message,
202
204
  })),
203
205
  ];
206
+ const deadline = AbortSignal.timeout(10_000);
207
+ const probeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
208
+ try {
209
+ const result = await new S3Client(this.config, probeSignal).diagnoseRead(
210
+ latestKey(this.config),
211
+ );
212
+ throwIfAborted(probeSignal);
213
+ const messages = {
214
+ readable:
215
+ "s3 remote read: HTTP success. Snapshot contents and write access are not tested.",
216
+ "missing-key":
217
+ "s3 remote read: no snapshot at this path (NoSuchKey). Write access is not tested; verify the bucket and path before first sync.",
218
+ "missing-bucket":
219
+ "s3 remote read: bucket not found (NoSuchBucket). Create the bucket with your provider or edit the setup's bucket name.",
220
+ "unknown-404":
221
+ "s3 remote read: HTTP 404; cannot confirm whether the bucket or snapshot is missing. Verify the endpoint, bucket, and path with your provider. Write access is not tested.",
222
+ };
223
+ diagnostics.push({
224
+ key: "s3-read",
225
+ level: result === "readable" ? "info" : "warning",
226
+ message: messages[result],
227
+ });
228
+ } catch (error) {
229
+ throwIfAborted(signal);
230
+ diagnostics.push({
231
+ key: "s3-read",
232
+ level: "warning",
233
+ message: syncErrorGuidance(
234
+ deadline.aborted ? new Error("S3 diagnostic request timed out.") : error,
235
+ ),
236
+ });
237
+ }
238
+ return diagnostics;
204
239
  }
205
240
 
206
241
  private async resolveChecksum(reference: string, signal?: AbortSignal) {
@@ -257,7 +292,7 @@ export function snapshotKey(config: ResolvedS3Backend, id: string) {
257
292
  }
258
293
 
259
294
  export function storageRoot(config: ResolvedS3Backend) {
260
- return config.destination.prefix;
295
+ return config.destination.prefix === "./" ? "" : config.destination.prefix;
261
296
  }
262
297
 
263
298
  export function pointerFor(
@@ -444,6 +479,7 @@ function assertSafeDestination(config: ResolvedS3Backend) {
444
479
  ["prefix", config.destination.prefix, true],
445
480
  ["namespace", config.destination.namespace, false],
446
481
  ] as const) {
482
+ if (label === "prefix" && value === "./") continue;
447
483
  if (
448
484
  (!allowEmpty && value.length === 0) ||
449
485
  value.includes("\\") ||
@@ -1,7 +1,7 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
- import { encodeKey, posixJoin } from "./paths.js";
4
- import type { RemoteObject, ResolvedS3Backend } from "./types.js";
3
+ import { encodeKey, posixJoin } from "../../paths.js";
4
+ import type { RemoteObject, ResolvedS3Backend } from "../backend-types.js";
5
5
 
6
6
  const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
7
7
  const MAX_SNAPSHOT_RESPONSE_BYTES = 256 * 1024 * 1024;
@@ -32,6 +32,16 @@ export class S3ObjectAlreadyExistsError extends Error {
32
32
  }
33
33
  }
34
34
 
35
+ export class S3HttpError extends Error {
36
+ constructor(
37
+ message: string,
38
+ readonly status: number,
39
+ ) {
40
+ super(message);
41
+ this.name = "S3HttpError";
42
+ }
43
+ }
44
+
35
45
  export class S3Client {
36
46
  private config: ResolvedS3Backend;
37
47
  private endpoint: URL;
@@ -50,6 +60,28 @@ export class S3Client {
50
60
  this.requestTimeoutMs = requestTimeoutMs;
51
61
  }
52
62
 
63
+ /** Read-only access probe. A bare 404 cannot distinguish a missing key from a missing bucket. */
64
+ async diagnoseRead(
65
+ key: string,
66
+ ): Promise<"readable" | "missing-key" | "missing-bucket" | "unknown-404"> {
67
+ this.signal?.throwIfAborted();
68
+ const response = await this.request("GET", key);
69
+ if (response.ok) {
70
+ await response.body?.cancel();
71
+ this.signal?.throwIfAborted();
72
+ return "readable";
73
+ }
74
+ const detail = await this.readErrorText(response);
75
+ this.signal?.throwIfAborted();
76
+ if (response.status === 404) {
77
+ const code = /<Code>\s*([A-Za-z]+)\s*<\/Code>/u.exec(detail)?.[1];
78
+ if (code === "NoSuchBucket") return "missing-bucket";
79
+ if (code === "NoSuchKey") return "missing-key";
80
+ return "unknown-404";
81
+ }
82
+ throw new S3HttpError(`S3 remote read failed (HTTP ${response.status}).`, response.status);
83
+ }
84
+
53
85
  async getJson<T>(key: string): Promise<RemoteObject<T>> {
54
86
  const maxAttempts = 3;
55
87
  let lastError: unknown;
@@ -57,7 +89,10 @@ export class S3Client {
57
89
  const object = await this.request("GET", key);
58
90
  if (object.status === 404) return { missing: true };
59
91
  if (!object.ok) {
60
- throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
92
+ throw new S3HttpError(
93
+ `S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
94
+ object.status,
95
+ );
61
96
  }
62
97
  const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
63
98
  // R2 can intermittently return an empty 200 body (read-after-write
@@ -87,7 +122,10 @@ export class S3Client {
87
122
  const object = await this.request("GET", key);
88
123
  if (object.status === 404) return { missing: true };
89
124
  if (!object.ok) {
90
- throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
125
+ throw new S3HttpError(
126
+ `S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
127
+ object.status,
128
+ );
91
129
  }
92
130
  const buffer = await readBoundedBuffer(
93
131
  object,
@@ -130,7 +168,10 @@ export class S3Client {
130
168
  throw new S3ObjectAlreadyExistsError(key);
131
169
  }
132
170
  if (!response.ok) {
133
- throw new Error(`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`);
171
+ throw new S3HttpError(
172
+ `S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
173
+ response.status,
174
+ );
134
175
  }
135
176
  }
136
177
 
@@ -173,8 +214,13 @@ export class S3Client {
173
214
  ? undefined
174
215
  : this.config.profile.sessionToken;
175
216
  const response = await send(sessionToken);
176
- if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
177
-
217
+ try {
218
+ if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
219
+ } catch (error) {
220
+ await response.body?.cancel().catch(() => undefined);
221
+ throw error;
222
+ }
223
+ await response.body?.cancel();
178
224
  const retry = await send(undefined);
179
225
  if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
180
226
  return retry;
@@ -246,6 +292,8 @@ async function readBoundedText(response: Response, limit: number, label: string)
246
292
  async function readBoundedBuffer(response: Response, limit: number, label: string) {
247
293
  const contentLength = Number(response.headers.get("content-length"));
248
294
  if (Number.isFinite(contentLength) && contentLength > limit) {
295
+ // Do not await cancellation of a cloned stream before its sibling can be released.
296
+ void response.body?.cancel().catch(() => undefined);
249
297
  throw new Error(`${label} exceeds the ${limit}-byte limit.`);
250
298
  }
251
299
  if (!response.body) return Buffer.alloc(0);
@@ -258,7 +306,7 @@ async function readBoundedBuffer(response: Response, limit: number, label: strin
258
306
  if (done) break;
259
307
  total += value.byteLength;
260
308
  if (total > limit) {
261
- await reader.cancel().catch(() => undefined);
309
+ void reader.cancel().catch(() => undefined);
262
310
  throw new Error(`${label} exceeds the ${limit}-byte limit.`);
263
311
  }
264
312
  chunks.push(Buffer.from(value));
@@ -1,4 +1,4 @@
1
- import type { Snapshot, SnapshotSelection } from "./types.js";
1
+ import type { Snapshot, SnapshotSelection } from "../snapshot/snapshot-types.js";
2
2
 
3
3
  export type PublicationCapability =
4
4
  | "read-check-write-verify"
@@ -1,5 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { decodeSnapshot, encodeSnapshot } from "./snapshot-codec.js";
2
+ import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
3
+ import type { Snapshot } from "../../snapshot/snapshot-types.js";
4
+ import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
5
+ import { portableSnapshotSelection } from "../../sync/sync-policy.js";
6
+ import type { LatestPointer, RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
3
7
  import {
4
8
  type BackendDiagnostic,
5
9
  type ExpectedRemoteHead,
@@ -10,9 +14,7 @@ import {
10
14
  type SyncBackend,
11
15
  SyncBackendConflictError,
12
16
  SyncBackendPublicationOutcomeUnknownError,
13
- } from "./sync-backend.js";
14
- import { portableSnapshotSelection } from "./sync-policy.js";
15
- import type { LatestPointer, RemoteObject, ResolvedWebDavBackend, Snapshot } from "./types.js";
17
+ } from "../sync-backend.js";
16
18
  import { WebDavClient, WebDavHttpError, WebDavPreconditionError } from "./webdav-client.js";
17
19
 
18
20
  const VERSION = 1;
@@ -198,6 +200,12 @@ export class WebDavSyncBackend implements SyncBackend {
198
200
  level: "info",
199
201
  message: `webdav URL/TLS/auth: configured (${webDavStorageLocation(this.config)})`,
200
202
  },
203
+ {
204
+ key: "webdav-scope",
205
+ level: "info",
206
+ message:
207
+ "webdav check scope: temporary write/delete probe; repair the active snapshot's history entry if missing. No local content is applied.",
208
+ },
201
209
  ];
202
210
  try {
203
211
  await this.runCapabilityProbe(signal);
@@ -217,7 +225,7 @@ export class WebDavSyncBackend implements SyncBackend {
217
225
  diagnostics.push({
218
226
  key: "webdav-probe",
219
227
  level: "error",
220
- message: `webdav publication is read-only until diagnostics pass: ${errorMessage(error)}`,
228
+ message: `webdav publication is read-only until diagnostics pass: ${syncErrorGuidance(error)}`,
221
229
  });
222
230
  return diagnostics;
223
231
  }
@@ -232,7 +240,7 @@ export class WebDavSyncBackend implements SyncBackend {
232
240
  diagnostics.push({
233
241
  key: "webdav-history",
234
242
  level: "error",
235
- message: `webdav history repair failed: ${errorMessage(error)}`,
243
+ message: `webdav history repair failed: ${syncErrorGuidance(error)}`,
236
244
  });
237
245
  }
238
246
  return diagnostics;
@@ -250,7 +258,7 @@ export class WebDavSyncBackend implements SyncBackend {
250
258
 
251
259
  private async runCapabilityProbe(signal?: AbortSignal) {
252
260
  throwIfAborted(signal);
253
- const probeCollection = `${rootPath(this.config)}/.pi-sync-probes/${randomUUID()}`;
261
+ const probeCollection = joinRemote(rootPath(this.config), ".pi-sync-probes", randomUUID());
254
262
  const probe = `${probeCollection}/conditional.txt`;
255
263
  const client = new WebDavClient(this.config, signal);
256
264
  let created = false;
@@ -382,7 +390,7 @@ export class WebDavSyncBackend implements SyncBackend {
382
390
  }
383
391
 
384
392
  export function rootPath(config: ResolvedWebDavBackend) {
385
- return config.destination.path;
393
+ return config.destination.path === "./" ? "" : config.destination.path;
386
394
  }
387
395
 
388
396
  export function latestPath(config: ResolvedWebDavBackend) {
@@ -410,7 +418,7 @@ export function webDavBackendIdentity(config: ResolvedWebDavBackend) {
410
418
 
411
419
  function webDavStorageLocation(config: ResolvedWebDavBackend) {
412
420
  const url = new URL(secretFreeUrl(config.profile.url));
413
- return `${url.host} · ${rootPath(config)}`;
421
+ return `${url.host} · ${config.destination.path}`;
414
422
  }
415
423
 
416
424
  function pointerFor(
@@ -586,7 +594,11 @@ function assertSafeDestination(config: ResolvedWebDavBackend) {
586
594
  const url = new URL(config.profile.url);
587
595
  if (url.username || url.password || url.search || url.hash)
588
596
  throw new Error("Invalid WebDAV URL.");
589
- for (const value of [config.destination.path, config.destination.namespace]) {
597
+ for (const [label, value] of [
598
+ ["path", config.destination.path],
599
+ ["namespace", config.destination.namespace],
600
+ ]) {
601
+ if (label === "path" && value === "./") continue;
590
602
  if (
591
603
  !value ||
592
604
  value.includes("\\") ||
@@ -1,5 +1,5 @@
1
1
  import { XMLParser } from "fast-xml-parser";
2
- import type { RemoteObject, ResolvedWebDavBackend } from "./types.js";
2
+ import type { RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
3
3
 
4
4
  const JSON_LIMIT = 1024 * 1024;
5
5
  const ERROR_LIMIT = 64 * 1024;
@@ -41,6 +41,7 @@ export function normalizeWebDavUrl(value: string | undefined) {
41
41
  }
42
42
 
43
43
  export function normalizeWebDavPath(value: string | undefined) {
44
+ if (value?.trim() === "." || value?.trim() === "./") return "./";
44
45
  const normalized = trimSlashes(normalizeOptionalString(value) ?? DEFAULT_PATH);
45
46
  if (
46
47
  !normalized ||