@narumitw/pi-sync 0.49.15 → 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 +49 -16
  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-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
  9. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  10. package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-AXXS2BPA.ts} +9 -9
  11. package/dist/chunks/chunk-AXXS2BPA.ts.map +7 -0
  12. package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
  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-WSOBYHIZ.ts → chunk-GWMHAQG4.ts} +4 -4
  21. package/dist/chunks/chunk-GWMHAQG4.ts.map +7 -0
  22. package/dist/chunks/{chunk-5LZALPCD.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-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -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-HVUSHMVL.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-GLQLN5GV.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-44LPGQTI.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-ARN2672F.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-RKEHALNQ.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-552LXHE4.ts → s3-backend-3P33LQHU.ts} +96 -18
  69. package/dist/chunks/s3-backend-3P33LQHU.ts.map +7 -0
  70. package/dist/chunks/{setup-switch-5YDG5YGJ.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-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
  74. package/dist/chunks/sync-inspection-FEP4L5TR.ts +20 -0
  75. package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-ONJ27QRL.ts} +374 -294
  76. package/dist/chunks/sync-operations-ONJ27QRL.ts.map +7 -0
  77. package/dist/chunks/{sync-state-SRN6YRBK.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 +30 -6
  84. package/package.json +1 -1
  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} +39 -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} +34 -325
  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 -286
  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} +17 -13
  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-44LPGQTI.ts.map +0 -7
  164. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  165. package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
  166. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  167. package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
  168. package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
  169. package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
  170. package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
  171. package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
  172. package/dist/chunks/chunk-E4COSV32.ts +0 -82
  173. package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
  174. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  175. package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
  176. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  177. package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
  178. package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
  179. package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
  180. package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
  181. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  182. package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
  183. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  184. package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
  185. package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
  186. package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
  187. package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
  188. package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
  189. package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
  190. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  191. package/src/config.ts +0 -923
  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-LWPYJAD5.ts.map → manager-result-dispatcher-U23ODD32.ts.map} +0 -0
  197. /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-ZE3TY67S.ts.map} +0 -0
  198. /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  199. /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  200. /package/dist/chunks/{sync-state-SRN6YRBK.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
@@ -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 ||
@@ -0,0 +1,330 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig, sessionTokenWarnings, syncSessionsWarnings } from "../settings/config.js";
3
+ import {
4
+ activeLocalConfigPath,
5
+ createLocalConfigDocument,
6
+ localConfigPath,
7
+ } from "../settings/config-file.js";
8
+ import { localConfigTemplate, readLocalConfigObject } from "../settings/settings-store.js";
9
+ import type { AnySyncConfig } from "../settings/settings-types.js";
10
+ import { unlock, withLock } from "../state/lock.js";
11
+ import {
12
+ migrateLegacyStateDirectory,
13
+ stateDirectoryMigrationNotice,
14
+ } from "../state/state-directory.js";
15
+ import { throwIfAborted } from "../sync/signals.js";
16
+ import { syncErrorGuidance } from "../sync/sync-error-guidance.js";
17
+ import { isSyncDecisionRequiredError, SetupPullRequiresUiError } from "../sync/sync-errors.js";
18
+ import type { SyncLoaders } from "../sync/sync-loaders.js";
19
+ import { RemoteSelectionMismatchError } from "../sync/sync-policy.js";
20
+ import { automaticSyncSummary } from "../ui/automatic-sync-summary.js";
21
+ import type { RunRouteResult } from "../ui/cancellable-operation.js";
22
+ import {
23
+ parseOptions,
24
+ resolveSyncCommand,
25
+ splitArgs,
26
+ usage,
27
+ validateCommandOptions,
28
+ } from "./command.js";
29
+ import type { CommandOptions } from "./command-types.js";
30
+
31
+ const STATUS_KEY = "sync";
32
+
33
+ export async function executeRecoveryCommand(
34
+ rawArgs: string,
35
+ ctx: ExtensionContext,
36
+ signal: AbortSignal | undefined,
37
+ loaders: SyncLoaders,
38
+ onCommit?: () => void,
39
+ setup?: string,
40
+ ): Promise<RunRouteResult> {
41
+ try {
42
+ const [subcommand, ...rest] = splitArgs(rawArgs);
43
+ if (subcommand !== "sync" && subcommand !== "pull" && subcommand !== "push") {
44
+ throw new Error(`Unsupported sync recovery route: ${subcommand ?? "missing"}`);
45
+ }
46
+ const options = parseOptions(rest);
47
+ if (setup !== undefined) options.setup = setup;
48
+ if (signal) options.signal = signal;
49
+ if (onCommit) options.onCommit = onCommit;
50
+ options.reload = false;
51
+ options.auto = false;
52
+ validateCommandOptions(subcommand, options);
53
+ const operations = await loaders.operations();
54
+ throwIfAborted(options.signal);
55
+ if (subcommand === "push") {
56
+ const outcome = await withLock("push", () => operations.push(ctx, options));
57
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
58
+ }
59
+ if (subcommand === "pull") {
60
+ const outcome = await withLock("pull", () => operations.pull(ctx, options));
61
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
62
+ }
63
+ await withLock("sync", () => operations.syncBoth(ctx, options));
64
+ return { kind: "completed" };
65
+ } catch (error) {
66
+ if (signal?.aborted) return { kind: "failed" };
67
+ ctx.ui.setStatus(STATUS_KEY, undefined);
68
+ if (error instanceof RemoteSelectionMismatchError) {
69
+ return { kind: "remote-selection-required", decision: error.decision };
70
+ }
71
+ if (isSyncDecisionRequiredError(error)) {
72
+ return { kind: "decision-required", decision: error.decision };
73
+ }
74
+ ctx.ui.notify(syncErrorGuidance(error), "error");
75
+ return { kind: "failed" };
76
+ }
77
+ }
78
+
79
+ export async function executeCommand(
80
+ rawArgs: string,
81
+ ctx: ExtensionCommandContext,
82
+ signal: AbortSignal | undefined,
83
+ loaders: SyncLoaders,
84
+ onCommit?: () => void,
85
+ setup?: string,
86
+ ): Promise<RunRouteResult> {
87
+ try {
88
+ const command = await resolveSyncCommand(rawArgs, ctx);
89
+ if (signal?.aborted || !command) return { kind: "completed" };
90
+ const { subcommand, rest } = command;
91
+ const options = parseOptions(rest);
92
+ if (setup !== undefined) options.setup = setup;
93
+ if (signal) options.signal = signal;
94
+ if (onCommit) options.onCommit = onCommit;
95
+ validateCommandOptions(subcommand, options);
96
+
97
+ switch (subcommand) {
98
+ case "help":
99
+ ctx.ui.notify(usage(), "info");
100
+ return { kind: "completed" };
101
+ case "use": {
102
+ const { useSyncSetup } = await loaders.setupSwitch();
103
+ throwIfAborted(options.signal);
104
+ await useSyncSetup(
105
+ ctx,
106
+ options.args[0] ?? "",
107
+ async (selectedSetup) => {
108
+ const operations = await loaders.operations();
109
+ throwIfAborted(options.signal);
110
+ return withLock("pull", () =>
111
+ operations.pull(ctx, { ...options, setup: selectedSetup }),
112
+ );
113
+ },
114
+ undefined,
115
+ options.signal,
116
+ );
117
+ return { kind: "completed" };
118
+ }
119
+ case "init":
120
+ await initConfig(ctx, signal);
121
+ return { kind: "completed" };
122
+ case "config":
123
+ await showConfig(ctx, options);
124
+ return { kind: "completed" };
125
+ case "files": {
126
+ const { showFileSelection } = await import("../ui/file-selection.js");
127
+ throwIfAborted(options.signal);
128
+ await showFileSelection(ctx, options.setup, options.signal);
129
+ return { kind: "completed" };
130
+ }
131
+ case "status": {
132
+ const operations = await loaders.operations();
133
+ throwIfAborted(options.signal);
134
+ await operations.status(ctx, options);
135
+ return { kind: "completed" };
136
+ }
137
+ case "diff": {
138
+ const operations = await loaders.operations();
139
+ throwIfAborted(options.signal);
140
+ await operations.diff(ctx, options);
141
+ return { kind: "completed" };
142
+ }
143
+ case "doctor": {
144
+ const operations = await loaders.operations();
145
+ throwIfAborted(options.signal);
146
+ await operations.doctor(ctx, options);
147
+ return { kind: "completed" };
148
+ }
149
+ case "push": {
150
+ const operations = await loaders.operations();
151
+ throwIfAborted(options.signal);
152
+ const outcome = await withLock("push", () => operations.push(ctx, options));
153
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
154
+ }
155
+ case "pull": {
156
+ const operations = await loaders.operations();
157
+ throwIfAborted(options.signal);
158
+ const outcome = await withLock("pull", () => operations.pull(ctx, options));
159
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
160
+ }
161
+ case "sync": {
162
+ const operations = await loaders.operations();
163
+ throwIfAborted(options.signal);
164
+ await withLock("sync", () => operations.syncBoth(ctx, options));
165
+ return { kind: "completed" };
166
+ }
167
+ case "history": {
168
+ const operations = await loaders.operations();
169
+ throwIfAborted(options.signal);
170
+ await operations.history(ctx, options);
171
+ return { kind: "completed" };
172
+ }
173
+ case "rollback": {
174
+ const operations = await loaders.operations();
175
+ throwIfAborted(options.signal);
176
+ await withLock("rollback", () => operations.rollback(ctx, options));
177
+ return { kind: "completed" };
178
+ }
179
+ case "migrate-state":
180
+ await migrateStateDirectory(ctx, options);
181
+ return { kind: "completed" };
182
+ case "unlock":
183
+ await unlock(ctx, options);
184
+ return { kind: "completed" };
185
+ default:
186
+ ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
187
+ return { kind: "failed" };
188
+ }
189
+ } catch (error) {
190
+ if (signal?.aborted) return { kind: "failed" };
191
+ ctx.ui.setStatus(STATUS_KEY, undefined);
192
+ if (error instanceof SetupPullRequiresUiError) throw error;
193
+ if (error instanceof RemoteSelectionMismatchError) {
194
+ return { kind: "remote-selection-required", decision: error.decision };
195
+ }
196
+ if (isSyncDecisionRequiredError(error)) {
197
+ return { kind: "decision-required", decision: error.decision };
198
+ }
199
+ ctx.ui.notify(syncErrorGuidance(error), "error");
200
+ return { kind: "failed" };
201
+ }
202
+ }
203
+
204
+ async function migrateStateDirectory(ctx: ExtensionCommandContext, options: CommandOptions) {
205
+ const notice = stateDirectoryMigrationNotice();
206
+ if (!notice) {
207
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
208
+ return;
209
+ }
210
+ if (
211
+ !options.yes &&
212
+ !(await ctx.ui.confirm(
213
+ "Migrate pi-sync state directory",
214
+ "Confirm that every other Pi process is closed. pi-sync will atomically rename .pisync/ to pi-sync/ without merging or deleting either root.",
215
+ { signal: options.signal },
216
+ ))
217
+ ) {
218
+ ctx.ui.notify("pi-sync state migration cancelled.", "info");
219
+ return;
220
+ }
221
+ throwIfAborted(options.signal);
222
+ const result = await migrateLegacyStateDirectory();
223
+ throwIfAborted(options.signal);
224
+ if (result.status === "ready") {
225
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
226
+ return;
227
+ }
228
+ ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
229
+ }
230
+
231
+ async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
232
+ const configPath = localConfigPath();
233
+ if (await readLocalConfigObject()) {
234
+ ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
235
+ return;
236
+ }
237
+
238
+ if (ctx.mode === "tui") {
239
+ const { showSetupWizard } = await import("../ui/setup/setup-wizard.js");
240
+ throwIfAborted(signal);
241
+ await showSetupWizard(ctx, signal);
242
+ return;
243
+ }
244
+ await createLocalConfigDocument(localConfigTemplate());
245
+ ctx.ui.notify(
246
+ `Created ${configPath}. Add a storage connection and sync setup before syncing.`,
247
+ "info",
248
+ );
249
+ }
250
+
251
+ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
252
+ const config = await loadConfig(options.setup);
253
+ const warnings = [
254
+ ...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
255
+ ...syncSessionsWarnings(config),
256
+ ];
257
+ const storageLines = configStorageLines(config);
258
+ ctx.ui.notify(
259
+ [
260
+ "pi-sync config:",
261
+ `sync setup: ${config.setupName}`,
262
+ `storage connection: ${config.connectionName}`,
263
+ ...storageLines,
264
+ `storage path: ${config.storagePath}`,
265
+ `automatic sync: ${automaticSyncSummary(config.automatic)}`,
266
+ `included content: ${config.include.join(", ") || "none"}`,
267
+ `sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
268
+ `settings file: ${localConfigPath()}`,
269
+ ...warnings,
270
+ ].join("\n"),
271
+ warnings.length > 0 ? "warning" : "info",
272
+ );
273
+ }
274
+
275
+ function configStorageLines(config: AnySyncConfig) {
276
+ switch (config.backend.type) {
277
+ case "git":
278
+ return [
279
+ "kind: git",
280
+ `remote: ${displayGitRemote(config.backend.profile.remote)}`,
281
+ "authentication: existing Git/SSH credentials (not stored)",
282
+ `branch: ${config.backend.destination.branch}`,
283
+ ];
284
+ case "webdav":
285
+ return [
286
+ "kind: webdav",
287
+ `url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
288
+ "username: configured (value hidden)",
289
+ "password: configured",
290
+ ];
291
+ case "s3":
292
+ return [
293
+ "kind: s3",
294
+ `endpoint: ${config.backend.profile.endpoint}`,
295
+ `bucket: ${config.backend.destination.bucket}`,
296
+ `region: ${config.backend.profile.region}`,
297
+ "access key id: configured",
298
+ "secret access key: configured",
299
+ `session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
300
+ ];
301
+ }
302
+ }
303
+
304
+ function displayGitRemote(value: string | undefined) {
305
+ if (!value) return "missing";
306
+ try {
307
+ const url = new URL(value);
308
+ url.username = "";
309
+ url.password = "";
310
+ url.search = "";
311
+ url.hash = "";
312
+ return url.toString();
313
+ } catch {
314
+ return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
315
+ }
316
+ }
317
+
318
+ function displayWebDavUrl(value: string | undefined, username: string | undefined) {
319
+ if (!value) return "missing";
320
+ try {
321
+ const url = new URL(value);
322
+ url.username = "";
323
+ url.password = "";
324
+ url.search = "";
325
+ url.hash = "";
326
+ return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
327
+ } catch {
328
+ return "invalid (value hidden)";
329
+ }
330
+ }