@narumitw/pi-sync 0.50.3 → 0.51.1

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 (218) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
  3. package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
  4. package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
  5. package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
  6. package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
  7. package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
  8. package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
  9. package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
  10. package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
  11. package/dist/chunks/chunk-6546TS54.ts.map +7 -0
  12. package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
  13. package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
  14. package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
  15. package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
  16. package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
  17. package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
  18. package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
  19. package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
  20. package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
  21. package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
  22. package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
  23. package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
  24. package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
  25. package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
  26. package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
  27. package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
  28. package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
  29. package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
  30. package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
  31. package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
  32. package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
  33. package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
  34. package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
  35. package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
  36. package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
  37. package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
  38. package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
  39. package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
  40. package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
  41. package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
  42. package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
  43. package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
  44. package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
  45. package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
  46. package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
  47. package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
  48. package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
  49. package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
  50. package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
  51. package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
  52. package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
  53. package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
  54. package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
  55. package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
  56. package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
  57. package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
  58. package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
  59. package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
  60. package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
  61. package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
  62. package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
  63. package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
  64. package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
  65. package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
  67. package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
  68. package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
  70. package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
  71. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
  73. package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
  74. package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
  75. package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
  76. package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
  77. package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
  78. package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
  79. package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
  80. package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
  81. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
  82. package/dist/index.ts +49 -74
  83. package/dist/index.ts.map +3 -3
  84. package/docs/settings.md +9 -0
  85. package/package.json +61 -62
  86. package/src/backends/backend-factory.ts +15 -15
  87. package/src/backends/backend-identity.ts +31 -31
  88. package/src/backends/backend-types.ts +42 -42
  89. package/src/backends/git/git-backend.ts +714 -788
  90. package/src/backends/git/git-config.ts +104 -111
  91. package/src/backends/git/git-runner.ts +230 -247
  92. package/src/backends/git/git-storage.ts +228 -245
  93. package/src/backends/s3/s3-backend.ts +408 -441
  94. package/src/backends/s3/s3-client.ts +288 -333
  95. package/src/backends/sync-backend.ts +65 -65
  96. package/src/backends/webdav/webdav-backend.ts +542 -589
  97. package/src/backends/webdav/webdav-client.ts +292 -316
  98. package/src/backends/webdav/webdav-config.ts +57 -68
  99. package/src/commands/command-execution.ts +270 -289
  100. package/src/commands/command-handler.ts +160 -163
  101. package/src/commands/command-types.ts +13 -13
  102. package/src/commands/command.ts +190 -196
  103. package/src/commands/setup-completions.ts +1 -1
  104. package/src/paths.ts +47 -47
  105. package/src/settings/config-errors.ts +4 -5
  106. package/src/settings/config-file.ts +473 -506
  107. package/src/settings/config.ts +171 -174
  108. package/src/settings/settings-management.ts +191 -216
  109. package/src/settings/settings-store.ts +63 -77
  110. package/src/settings/settings-types.ts +78 -75
  111. package/src/settings/settings-validation.ts +328 -364
  112. package/src/snapshot/session-paths.ts +41 -55
  113. package/src/snapshot/snapshot-apply.ts +197 -229
  114. package/src/snapshot/snapshot-codec.ts +29 -31
  115. package/src/snapshot/snapshot-paths.ts +4 -6
  116. package/src/snapshot/snapshot-transaction.ts +161 -170
  117. package/src/snapshot/snapshot-types.ts +24 -24
  118. package/src/snapshot/snapshot.ts +310 -343
  119. package/src/state/json-file.ts +11 -11
  120. package/src/state/lock.ts +226 -242
  121. package/src/state/lockfile-fs.ts +20 -20
  122. package/src/state/operation-availability.ts +36 -41
  123. package/src/state/state-directory.ts +171 -173
  124. package/src/state/state-types.ts +15 -15
  125. package/src/state/sync-state-store.ts +23 -23
  126. package/src/sync/automatic-sync.ts +75 -80
  127. package/src/sync/remote-snapshot.ts +52 -60
  128. package/src/sync/setup-switch.ts +99 -114
  129. package/src/sync/signals.ts +3 -5
  130. package/src/sync/startup-check.ts +97 -111
  131. package/src/sync/sync-decision.ts +76 -80
  132. package/src/sync/sync-error-guidance.ts +41 -48
  133. package/src/sync/sync-errors.ts +10 -10
  134. package/src/sync/sync-inspection.ts +29 -33
  135. package/src/sync/sync-loaders.ts +28 -36
  136. package/src/sync/sync-mutations.ts +510 -622
  137. package/src/sync/sync-operations.ts +7 -7
  138. package/src/sync/sync-policy.ts +296 -326
  139. package/src/sync/sync-queries.ts +175 -194
  140. package/src/sync/sync-state.ts +74 -100
  141. package/src/sync-extension.ts +89 -101
  142. package/src/sync.ts +26 -26
  143. package/src/types.ts +37 -37
  144. package/src/ui/automatic-sync-summary.ts +1 -3
  145. package/src/ui/cancellable-operation.ts +89 -101
  146. package/src/ui/file-selection.ts +250 -274
  147. package/src/ui/manager-attention.ts +51 -63
  148. package/src/ui/manager-recovery.ts +48 -57
  149. package/src/ui/manager-result-dispatcher.ts +62 -76
  150. package/src/ui/manager-state.ts +200 -232
  151. package/src/ui/manager-ui.ts +264 -362
  152. package/src/ui/remote-selection-ui.ts +547 -588
  153. package/src/ui/secret-input.ts +197 -205
  154. package/src/ui/settings-ui.ts +175 -172
  155. package/src/ui/setup/git-ui.ts +232 -261
  156. package/src/ui/setup/s3-credentials-ui.ts +58 -65
  157. package/src/ui/setup/s3-ui.ts +171 -195
  158. package/src/ui/setup/setup-actions.ts +91 -101
  159. package/src/ui/setup/setup-location-ui.ts +121 -137
  160. package/src/ui/setup/setup-name-ui.ts +27 -27
  161. package/src/ui/setup/setup-prompts.ts +65 -65
  162. package/src/ui/setup/setup-review.ts +58 -59
  163. package/src/ui/setup/setup-switcher.ts +156 -160
  164. package/src/ui/setup/setup-wizard.ts +28 -28
  165. package/src/ui/setup/text-input.ts +54 -58
  166. package/src/ui/setup/webdav-ui.ts +246 -293
  167. package/src/ui/storage-connections-ui.ts +317 -369
  168. package/src/ui/sync-attention.ts +148 -164
  169. package/src/ui/sync-format.ts +110 -121
  170. package/src/ui/sync-resolution-ui.ts +147 -153
  171. package/src/ui/sync-setups-ui.ts +218 -238
  172. package/src/ui/sync-status.ts +17 -0
  173. package/src/ui/terminal-text.ts +2 -2
  174. package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
  175. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  176. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  177. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  178. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  179. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  180. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  181. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  182. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  183. package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  186. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  187. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  188. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  189. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  190. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  191. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  192. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  193. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  194. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  195. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  196. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  197. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  198. package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
  199. package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
  200. package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
  201. package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
  202. package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
  203. package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
  204. package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
  205. package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
  206. package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
  210. package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
  211. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
  212. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
  213. /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
  215. /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
  216. /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
  217. /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
  218. /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
@@ -9,382 +9,358 @@ const REQUEST_TIMEOUT_MS = 30_000;
9
9
  const MAX_REDIRECTS = 3;
10
10
 
11
11
  export interface WebDavPutOptions {
12
- ifAbsent?: boolean;
13
- ifMatch?: string;
12
+ ifAbsent?: boolean;
13
+ ifMatch?: string;
14
14
  }
15
15
 
16
16
  export interface WebDavEntry {
17
- href: string;
18
- etag?: string;
19
- collection: boolean;
17
+ href: string;
18
+ etag?: string;
19
+ collection: boolean;
20
20
  }
21
21
 
22
22
  export class WebDavHttpError extends Error {
23
- constructor(
24
- message: string,
25
- readonly status: number,
26
- options?: ErrorOptions,
27
- ) {
28
- super(message, options);
29
- this.name = "WebDavHttpError";
30
- }
23
+ constructor(
24
+ message: string,
25
+ readonly status: number,
26
+ options?: ErrorOptions,
27
+ ) {
28
+ super(message, options);
29
+ this.name = "WebDavHttpError";
30
+ }
31
31
  }
32
32
 
33
33
  export class WebDavPreconditionError extends WebDavHttpError {
34
- constructor(message = "WebDAV precondition failed.") {
35
- super(message, 412);
36
- this.name = "WebDavPreconditionError";
37
- }
34
+ constructor(message = "WebDAV precondition failed.") {
35
+ super(message, 412);
36
+ this.name = "WebDavPreconditionError";
37
+ }
38
38
  }
39
39
 
40
40
  export class WebDavClient {
41
- private readonly baseUrl: URL;
42
- private readonly authorization: string;
41
+ private readonly baseUrl: URL;
42
+ private readonly authorization: string;
43
43
 
44
- constructor(
45
- private readonly config: ResolvedWebDavBackend,
46
- private readonly signal?: AbortSignal,
47
- private readonly timeoutMs = REQUEST_TIMEOUT_MS,
48
- ) {
49
- this.baseUrl = new URL(config.profile.url);
50
- assertSafeAuthenticatedUrl(this.baseUrl);
51
- if (
52
- !config.profile.username ||
53
- !config.profile.password ||
54
- config.profile.username.includes(":") ||
55
- hasControlCharacter(config.profile.username) ||
56
- hasControlCharacter(config.profile.password)
57
- ) {
58
- throw new Error("Invalid WebDAV credentials.");
59
- }
60
- this.authorization = `Basic ${Buffer.from(`${config.profile.username}:${config.profile.password}`).toString("base64")}`;
61
- }
44
+ constructor(
45
+ private readonly config: ResolvedWebDavBackend,
46
+ private readonly signal?: AbortSignal,
47
+ private readonly timeoutMs = REQUEST_TIMEOUT_MS,
48
+ ) {
49
+ this.baseUrl = new URL(config.profile.url);
50
+ assertSafeAuthenticatedUrl(this.baseUrl);
51
+ if (
52
+ !config.profile.username ||
53
+ !config.profile.password ||
54
+ config.profile.username.includes(":") ||
55
+ hasControlCharacter(config.profile.username) ||
56
+ hasControlCharacter(config.profile.password)
57
+ ) {
58
+ throw new Error("Invalid WebDAV credentials.");
59
+ }
60
+ this.authorization = `Basic ${Buffer.from(`${config.profile.username}:${config.profile.password}`).toString("base64")}`;
61
+ }
62
62
 
63
- async getBuffer(remotePath: string): Promise<RemoteObject<Buffer>> {
64
- const response = await this.request(remotePath, { method: "GET" });
65
- if (response.status === 404) {
66
- await response.body?.cancel();
67
- return { missing: true };
68
- }
69
- await this.requireOk(response, "read");
70
- return {
71
- value: await readBounded(response, SNAPSHOT_LIMIT, "WebDAV response is too large"),
72
- etag: response.headers.get("etag") ?? undefined,
73
- missing: false,
74
- };
75
- }
63
+ async getBuffer(remotePath: string): Promise<RemoteObject<Buffer>> {
64
+ const response = await this.request(remotePath, { method: "GET" });
65
+ if (response.status === 404) {
66
+ await response.body?.cancel();
67
+ return { missing: true };
68
+ }
69
+ await this.requireOk(response, "read");
70
+ return {
71
+ value: await readBounded(response, SNAPSHOT_LIMIT, "WebDAV response is too large"),
72
+ etag: response.headers.get("etag") ?? undefined,
73
+ missing: false,
74
+ };
75
+ }
76
76
 
77
- async getJson<T>(remotePath: string): Promise<RemoteObject<T>> {
78
- const response = await this.request(remotePath, { method: "GET" });
79
- if (response.status === 404) {
80
- await response.body?.cancel();
81
- return { missing: true };
82
- }
83
- await this.requireOk(response, "read");
84
- const bytes = await readBounded(response, JSON_LIMIT, "WebDAV JSON response is too large");
85
- try {
86
- return {
87
- value: JSON.parse(bytes.toString("utf8")) as T,
88
- etag: response.headers.get("etag") ?? undefined,
89
- missing: false,
90
- };
91
- } catch (error) {
92
- throw new Error("WebDAV JSON response is malformed.", { cause: error });
93
- }
94
- }
77
+ async getJson<T>(remotePath: string): Promise<RemoteObject<T>> {
78
+ const response = await this.request(remotePath, { method: "GET" });
79
+ if (response.status === 404) {
80
+ await response.body?.cancel();
81
+ return { missing: true };
82
+ }
83
+ await this.requireOk(response, "read");
84
+ const bytes = await readBounded(response, JSON_LIMIT, "WebDAV JSON response is too large");
85
+ try {
86
+ return {
87
+ value: JSON.parse(bytes.toString("utf8")) as T,
88
+ etag: response.headers.get("etag") ?? undefined,
89
+ missing: false,
90
+ };
91
+ } catch (error) {
92
+ throw new Error("WebDAV JSON response is malformed.", { cause: error });
93
+ }
94
+ }
95
95
 
96
- async putBuffer(
97
- remotePath: string,
98
- body: Buffer,
99
- contentType: string,
100
- options: WebDavPutOptions = {},
101
- ) {
102
- const headers: Record<string, string> = { "content-type": contentType };
103
- if (options.ifAbsent) headers["if-none-match"] = "*";
104
- if (options.ifMatch) headers["if-match"] = options.ifMatch;
105
- const response = await this.request(remotePath, {
106
- method: "PUT",
107
- headers,
108
- body: body as unknown as BodyInit,
109
- });
110
- if (response.status === 412) {
111
- await response.body?.cancel();
112
- throw new WebDavPreconditionError();
113
- }
114
- await this.requireOk(response, "write");
115
- await response.body?.cancel();
116
- return response.headers.get("etag") ?? undefined;
117
- }
96
+ async putBuffer(remotePath: string, body: Buffer, contentType: string, options: WebDavPutOptions = {}) {
97
+ const headers: Record<string, string> = { "content-type": contentType };
98
+ if (options.ifAbsent) headers["if-none-match"] = "*";
99
+ if (options.ifMatch) headers["if-match"] = options.ifMatch;
100
+ const response = await this.request(remotePath, {
101
+ method: "PUT",
102
+ headers,
103
+ body: body as unknown as BodyInit,
104
+ });
105
+ if (response.status === 412) {
106
+ await response.body?.cancel();
107
+ throw new WebDavPreconditionError();
108
+ }
109
+ await this.requireOk(response, "write");
110
+ await response.body?.cancel();
111
+ return response.headers.get("etag") ?? undefined;
112
+ }
118
113
 
119
- async putJson(remotePath: string, value: unknown, options: WebDavPutOptions = {}) {
120
- return this.putBuffer(
121
- remotePath,
122
- Buffer.from(JSON.stringify(value)),
123
- "application/json",
124
- options,
125
- );
126
- }
114
+ async putJson(remotePath: string, value: unknown, options: WebDavPutOptions = {}) {
115
+ return this.putBuffer(remotePath, Buffer.from(JSON.stringify(value)), "application/json", options);
116
+ }
127
117
 
128
- async makeCollection(remotePath: string) {
129
- const response = await this.request(remotePath, { method: "MKCOL" });
130
- if (response.status === 405) {
131
- await response.body?.cancel();
132
- return false;
133
- }
134
- await this.requireOk(response, "create collection");
135
- await response.body?.cancel();
136
- return true;
137
- }
118
+ async makeCollection(remotePath: string) {
119
+ const response = await this.request(remotePath, { method: "MKCOL" });
120
+ if (response.status === 405) {
121
+ await response.body?.cancel();
122
+ return false;
123
+ }
124
+ await this.requireOk(response, "create collection");
125
+ await response.body?.cancel();
126
+ return true;
127
+ }
138
128
 
139
- async ensureCollection(remotePath: string) {
140
- const segments = safeSegments(remotePath);
141
- for (let index = 1; index <= segments.length; index += 1) {
142
- await this.makeCollection(segments.slice(0, index).join("/"));
143
- }
144
- }
129
+ async ensureCollection(remotePath: string) {
130
+ const segments = safeSegments(remotePath);
131
+ for (let index = 1; index <= segments.length; index += 1) {
132
+ await this.makeCollection(segments.slice(0, index).join("/"));
133
+ }
134
+ }
145
135
 
146
- async listCollection(remotePath: string): Promise<WebDavEntry[]> {
147
- const response = await this.request(remotePath, {
148
- method: "PROPFIND",
149
- headers: { depth: "1", "content-type": "application/xml; charset=utf-8" },
150
- body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>',
151
- });
152
- if (response.status === 404) {
153
- await response.body?.cancel();
154
- throw new WebDavHttpError("WebDAV collection is missing.", 404);
155
- }
156
- if (response.status !== 207) await this.requireOk(response, "list collection");
157
- const xml = (
158
- await readBounded(response, XML_LIMIT, "WebDAV directory response is too large")
159
- ).toString("utf8");
160
- try {
161
- const parsed = new XMLParser({
162
- removeNSPrefix: true,
163
- ignoreAttributes: false,
164
- processEntities: false,
165
- }).parse(xml) as {
166
- multistatus?: { response?: unknown };
167
- };
168
- if (!parsed.multistatus || parsed.multistatus.response === undefined) {
169
- throw new Error("Missing DAV multistatus response.");
170
- }
171
- const responses = asArray(parsed.multistatus.response);
172
- return responses.map(parseEntry);
173
- } catch (error) {
174
- throw new Error("WebDAV directory response is malformed.", { cause: error });
175
- }
176
- }
136
+ async listCollection(remotePath: string): Promise<WebDavEntry[]> {
137
+ const response = await this.request(remotePath, {
138
+ method: "PROPFIND",
139
+ headers: { depth: "1", "content-type": "application/xml; charset=utf-8" },
140
+ body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>',
141
+ });
142
+ if (response.status === 404) {
143
+ await response.body?.cancel();
144
+ throw new WebDavHttpError("WebDAV collection is missing.", 404);
145
+ }
146
+ if (response.status !== 207) await this.requireOk(response, "list collection");
147
+ const xml = (await readBounded(response, XML_LIMIT, "WebDAV directory response is too large")).toString("utf8");
148
+ try {
149
+ const parsed = new XMLParser({
150
+ removeNSPrefix: true,
151
+ ignoreAttributes: false,
152
+ processEntities: false,
153
+ }).parse(xml) as {
154
+ multistatus?: { response?: unknown };
155
+ };
156
+ if (!parsed.multistatus || parsed.multistatus.response === undefined) {
157
+ throw new Error("Missing DAV multistatus response.");
158
+ }
159
+ const responses = asArray(parsed.multistatus.response);
160
+ return responses.map(parseEntry);
161
+ } catch (error) {
162
+ throw new Error("WebDAV directory response is malformed.", { cause: error });
163
+ }
164
+ }
177
165
 
178
- async delete(remotePath: string) {
179
- const response = await this.request(remotePath, { method: "DELETE" });
180
- if (response.status === 404) {
181
- await response.body?.cancel();
182
- return;
183
- }
184
- if (response.status === 207) {
185
- await response.body?.cancel();
186
- throw new WebDavHttpError("WebDAV probe cleanup returned a partial response.", 207);
187
- }
188
- await this.requireOk(response, "delete probe resource");
189
- await response.body?.cancel();
190
- }
166
+ async delete(remotePath: string) {
167
+ const response = await this.request(remotePath, { method: "DELETE" });
168
+ if (response.status === 404) {
169
+ await response.body?.cancel();
170
+ return;
171
+ }
172
+ if (response.status === 207) {
173
+ await response.body?.cancel();
174
+ throw new WebDavHttpError("WebDAV probe cleanup returned a partial response.", 207);
175
+ }
176
+ await this.requireOk(response, "delete probe resource");
177
+ await response.body?.cancel();
178
+ }
191
179
 
192
- private async request(remotePath: string, init: RequestInit) {
193
- throwIfAborted(this.signal);
194
- let url = appendRemotePath(this.baseUrl, remotePath);
195
- for (let redirects = 0; ; redirects += 1) {
196
- const timeout = AbortSignal.timeout(this.timeoutMs);
197
- const signal = this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
198
- let response: Response;
199
- try {
200
- response = await fetch(url, {
201
- ...init,
202
- headers: {
203
- accept: "*/*",
204
- authorization: this.authorization,
205
- ...headersObject(init.headers),
206
- },
207
- redirect: "manual",
208
- signal,
209
- });
210
- } catch (error) {
211
- throwIfAborted(this.signal);
212
- throw new Error(
213
- `WebDAV ${String(init.method ?? "GET")} request failed for ${redactText(safeUrl(url), this.config)}.`,
214
- {
215
- cause: redactError(error, this.config),
216
- },
217
- );
218
- }
219
- if (![301, 302, 303, 307, 308].includes(response.status)) return response;
220
- await response.body?.cancel();
221
- const method = String(init.method ?? "GET").toUpperCase();
222
- if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
223
- throw new Error(
224
- `WebDAV refused an ambiguous HTTP ${response.status} redirect for ${method}; configure the canonical collection URL or require HTTP 307/308.`,
225
- );
226
- }
227
- if (redirects >= MAX_REDIRECTS) throw new Error("WebDAV redirect limit exceeded.");
228
- const location = response.headers.get("location");
229
- if (!location) throw new Error("WebDAV redirect is missing Location.");
230
- const next = new URL(location, url);
231
- if (next.origin !== this.baseUrl.origin) {
232
- throw new Error("WebDAV refused a cross-origin authenticated redirect.");
233
- }
234
- url = next;
235
- }
236
- }
180
+ private async request(remotePath: string, init: RequestInit) {
181
+ throwIfAborted(this.signal);
182
+ let url = appendRemotePath(this.baseUrl, remotePath);
183
+ for (let redirects = 0; ; redirects += 1) {
184
+ const timeout = AbortSignal.timeout(this.timeoutMs);
185
+ const signal = this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
186
+ let response: Response;
187
+ try {
188
+ response = await fetch(url, {
189
+ ...init,
190
+ headers: {
191
+ accept: "*/*",
192
+ authorization: this.authorization,
193
+ ...headersObject(init.headers),
194
+ },
195
+ redirect: "manual",
196
+ signal,
197
+ });
198
+ } catch (error) {
199
+ throwIfAborted(this.signal);
200
+ throw new Error(
201
+ `WebDAV ${String(init.method ?? "GET")} request failed for ${redactText(safeUrl(url), this.config)}.`,
202
+ {
203
+ cause: redactError(error, this.config),
204
+ },
205
+ );
206
+ }
207
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
208
+ await response.body?.cancel();
209
+ const method = String(init.method ?? "GET").toUpperCase();
210
+ if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
211
+ throw new Error(
212
+ `WebDAV refused an ambiguous HTTP ${response.status} redirect for ${method}; configure the canonical collection URL or require HTTP 307/308.`,
213
+ );
214
+ }
215
+ if (redirects >= MAX_REDIRECTS) throw new Error("WebDAV redirect limit exceeded.");
216
+ const location = response.headers.get("location");
217
+ if (!location) throw new Error("WebDAV redirect is missing Location.");
218
+ const next = new URL(location, url);
219
+ if (next.origin !== this.baseUrl.origin) {
220
+ throw new Error("WebDAV refused a cross-origin authenticated redirect.");
221
+ }
222
+ url = next;
223
+ }
224
+ }
237
225
 
238
- private async requireOk(response: Response, action: string) {
239
- if (response.ok) return;
240
- const body = (await readBounded(response, ERROR_LIMIT, "WebDAV error body is too large"))
241
- .toString("utf8")
242
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote error text is untrusted terminal input.
243
- .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
244
- .trim();
245
- const detail = body ? `: ${redactText(body, this.config)}` : "";
246
- throw new WebDavHttpError(
247
- `WebDAV ${action} failed with HTTP ${response.status}${detail}`,
248
- response.status,
249
- );
250
- }
226
+ private async requireOk(response: Response, action: string) {
227
+ if (response.ok) return;
228
+ const body = (await readBounded(response, ERROR_LIMIT, "WebDAV error body is too large"))
229
+ .toString("utf8")
230
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote error text is untrusted terminal input.
231
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
232
+ .trim();
233
+ const detail = body ? `: ${redactText(body, this.config)}` : "";
234
+ throw new WebDavHttpError(`WebDAV ${action} failed with HTTP ${response.status}${detail}`, response.status);
235
+ }
251
236
  }
252
237
 
253
238
  function assertSafeAuthenticatedUrl(url: URL) {
254
- const loopback =
255
- url.hostname === "127.0.0.1" ||
256
- url.hostname === "localhost" ||
257
- url.hostname === "[::1]" ||
258
- url.hostname === "::1";
259
- if (
260
- url.username ||
261
- url.password ||
262
- url.search ||
263
- url.hash ||
264
- (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
265
- ) {
266
- throw new Error(
267
- "Invalid WebDAV URL: HTTPS is required and embedded credentials, query, or fragment are not allowed.",
268
- );
269
- }
239
+ const loopback =
240
+ url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]" || url.hostname === "::1";
241
+ if (
242
+ url.username ||
243
+ url.password ||
244
+ url.search ||
245
+ url.hash ||
246
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
247
+ ) {
248
+ throw new Error(
249
+ "Invalid WebDAV URL: HTTPS is required and embedded credentials, query, or fragment are not allowed.",
250
+ );
251
+ }
270
252
  }
271
253
 
272
254
  function hasControlCharacter(value: string) {
273
- return [...value].some((character) => {
274
- const code = character.codePointAt(0) ?? 0;
275
- return code < 0x20 || (code >= 0x7f && code <= 0x9f);
276
- });
255
+ return [...value].some((character) => {
256
+ const code = character.codePointAt(0) ?? 0;
257
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
258
+ });
277
259
  }
278
260
 
279
261
  function appendRemotePath(base: URL, remotePath: string) {
280
- const url = new URL(base);
281
- const suffix = safeSegments(remotePath).map(encodeURIComponent).join("/");
282
- url.pathname = `${base.pathname.replace(/\/+$/u, "")}/${suffix}`;
283
- return url;
262
+ const url = new URL(base);
263
+ const suffix = safeSegments(remotePath).map(encodeURIComponent).join("/");
264
+ url.pathname = `${base.pathname.replace(/\/+$/u, "")}/${suffix}`;
265
+ return url;
284
266
  }
285
267
 
286
268
  function safeSegments(value: string) {
287
- const segments = value
288
- .replace(/^\/+|\/+$/gu, "")
289
- .split("/")
290
- .filter(Boolean);
291
- if (
292
- segments.some(
293
- (segment) =>
294
- segment === "." ||
295
- segment === ".." ||
296
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote path segments cannot contain controls.
297
- /[\u0000-\u001f]/u.test(segment),
298
- )
299
- ) {
300
- throw new Error("Invalid WebDAV remote path.");
301
- }
302
- return segments;
269
+ const segments = value
270
+ .replace(/^\/+|\/+$/gu, "")
271
+ .split("/")
272
+ .filter(Boolean);
273
+ if (
274
+ segments.some(
275
+ (segment) =>
276
+ segment === "." ||
277
+ segment === ".." ||
278
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote path segments cannot contain controls.
279
+ /[\u0000-\u001f]/u.test(segment),
280
+ )
281
+ ) {
282
+ throw new Error("Invalid WebDAV remote path.");
283
+ }
284
+ return segments;
303
285
  }
304
286
 
305
287
  async function readBounded(response: Response, limit: number, message: string) {
306
- const length = Number(response.headers.get("content-length"));
307
- if (Number.isFinite(length) && length > limit) {
308
- await response.body?.cancel();
309
- throw new Error(message);
310
- }
311
- if (!response.body) return Buffer.alloc(0);
312
- const reader = response.body.getReader();
313
- const chunks: Buffer[] = [];
314
- let total = 0;
315
- try {
316
- while (true) {
317
- const { done, value } = await reader.read();
318
- if (done) break;
319
- total += value.byteLength;
320
- if (total > limit) throw new Error(message);
321
- chunks.push(Buffer.from(value));
322
- }
323
- } catch (error) {
324
- await reader.cancel().catch(() => undefined);
325
- throw error;
326
- }
327
- return Buffer.concat(chunks, total);
288
+ const length = Number(response.headers.get("content-length"));
289
+ if (Number.isFinite(length) && length > limit) {
290
+ await response.body?.cancel();
291
+ throw new Error(message);
292
+ }
293
+ if (!response.body) return Buffer.alloc(0);
294
+ const reader = response.body.getReader();
295
+ const chunks: Buffer[] = [];
296
+ let total = 0;
297
+ try {
298
+ while (true) {
299
+ const { done, value } = await reader.read();
300
+ if (done) break;
301
+ total += value.byteLength;
302
+ if (total > limit) throw new Error(message);
303
+ chunks.push(Buffer.from(value));
304
+ }
305
+ } catch (error) {
306
+ await reader.cancel().catch(() => undefined);
307
+ throw error;
308
+ }
309
+ return Buffer.concat(chunks, total);
328
310
  }
329
311
 
330
312
  function parseEntry(value: unknown): WebDavEntry {
331
- if (!value || typeof value !== "object") throw new Error("Invalid WebDAV response entry.");
332
- const record = value as Record<string, unknown>;
333
- const href = typeof record.href === "string" ? record.href : undefined;
334
- if (!href) throw new Error("Invalid WebDAV response href.");
335
- const propstat = asArray(record.propstat).find((item) => {
336
- if (!item || typeof item !== "object") return false;
337
- return String((item as Record<string, unknown>).status ?? "").includes(" 200 ");
338
- }) as Record<string, unknown> | undefined;
339
- const prop = propstat?.prop as Record<string, unknown> | undefined;
340
- return {
341
- href,
342
- etag: typeof prop?.getetag === "string" ? prop.getetag : undefined,
343
- collection:
344
- !!prop?.resourcetype &&
345
- typeof prop.resourcetype === "object" &&
346
- Object.hasOwn(prop.resourcetype, "collection"),
347
- };
313
+ if (!value || typeof value !== "object") throw new Error("Invalid WebDAV response entry.");
314
+ const record = value as Record<string, unknown>;
315
+ const href = typeof record.href === "string" ? record.href : undefined;
316
+ if (!href) throw new Error("Invalid WebDAV response href.");
317
+ const propstat = asArray(record.propstat).find((item) => {
318
+ if (!item || typeof item !== "object") return false;
319
+ return String((item as Record<string, unknown>).status ?? "").includes(" 200 ");
320
+ }) as Record<string, unknown> | undefined;
321
+ const prop = propstat?.prop as Record<string, unknown> | undefined;
322
+ return {
323
+ href,
324
+ etag: typeof prop?.getetag === "string" ? prop.getetag : undefined,
325
+ collection:
326
+ !!prop?.resourcetype && typeof prop.resourcetype === "object" && Object.hasOwn(prop.resourcetype, "collection"),
327
+ };
348
328
  }
349
329
 
350
330
  function asArray(value: unknown): unknown[] {
351
- if (value === undefined) return [];
352
- return Array.isArray(value) ? value : [value];
331
+ if (value === undefined) return [];
332
+ return Array.isArray(value) ? value : [value];
353
333
  }
354
334
 
355
335
  function headersObject(headers: HeadersInit | undefined) {
356
- return Object.fromEntries(new Headers(headers).entries());
336
+ return Object.fromEntries(new Headers(headers).entries());
357
337
  }
358
338
 
359
339
  function safeUrl(url: URL) {
360
- return `${url.protocol}//${url.host}${url.pathname}`;
340
+ return `${url.protocol}//${url.host}${url.pathname}`;
361
341
  }
362
342
 
363
343
  function redactError(error: unknown, config: ResolvedWebDavBackend) {
364
- const message = error instanceof Error ? error.message : String(error);
365
- return new Error(redactText(message, config));
344
+ const message = error instanceof Error ? error.message : String(error);
345
+ return new Error(redactText(message, config));
366
346
  }
367
347
 
368
348
  function redactText(value: string, config: ResolvedWebDavBackend) {
369
- let result = value;
370
- for (const secret of [config.profile.username, config.profile.password, config.profile.url]) {
371
- if (!secret) continue;
372
- for (const variant of new Set([secret, encodeURIComponent(secret)])) {
373
- result = result.replace(new RegExp(escapeRegExp(variant), "giu"), "[redacted]");
374
- }
375
- }
376
- return result
377
- .replace(/basic\s+[a-z0-9+/=]+/giu, "Basic [redacted]")
378
- .replace(/\?[^\s]*/gu, "?[redacted]");
349
+ let result = value;
350
+ for (const secret of [config.profile.username, config.profile.password, config.profile.url]) {
351
+ if (!secret) continue;
352
+ for (const variant of new Set([secret, encodeURIComponent(secret)])) {
353
+ result = result.replace(new RegExp(escapeRegExp(variant), "giu"), "[redacted]");
354
+ }
355
+ }
356
+ return result.replace(/basic\s+[a-z0-9+/=]+/giu, "Basic [redacted]").replace(/\?[^\s]*/gu, "?[redacted]");
379
357
  }
380
358
 
381
359
  function escapeRegExp(value: string) {
382
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
360
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
383
361
  }
384
362
 
385
363
  function throwIfAborted(signal?: AbortSignal) {
386
- if (!signal?.aborted) return;
387
- throw signal.reason instanceof Error
388
- ? signal.reason
389
- : new DOMException("The operation was aborted", "AbortError");
364
+ if (!signal?.aborted) return;
365
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
390
366
  }