@narumitw/pi-sync 0.50.3 → 0.51.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 (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
@@ -1,443 +1,407 @@
1
1
  import path from "node:path";
2
2
  import { normalizeEndpointIdentity } from "../backends/backend-identity.js";
3
3
  import {
4
- normalizeGitBranch,
5
- normalizeGitDirectory,
6
- normalizeGitRemote,
7
- normalizeGitRemoteIdentity,
4
+ normalizeGitBranch,
5
+ normalizeGitDirectory,
6
+ normalizeGitRemote,
7
+ normalizeGitRemoteIdentity,
8
8
  } from "../backends/git/git-config.js";
9
9
  import {
10
- normalizeWebDavIdentityUrl,
11
- normalizeWebDavPath,
12
- normalizeWebDavUrl,
13
- validateWebDavCredentials,
10
+ normalizeWebDavIdentityUrl,
11
+ normalizeWebDavPath,
12
+ normalizeWebDavUrl,
13
+ validateWebDavCredentials,
14
14
  } from "../backends/webdav/webdav-config.js";
15
15
  import { normalizeSyncInclude } from "../sync/sync-policy.js";
16
16
  import { localConfigPath } from "./config-file.js";
17
17
  import type {
18
- OnSwitchAction,
19
- PiSyncSettingsV3,
20
- StorageConnectionSettings,
21
- SyncSetupSettings,
18
+ OnSwitchAction,
19
+ PiSyncSettingsV3,
20
+ StorageConnectionSettings,
21
+ SyncSetupSettings,
22
22
  } from "./settings-types.js";
23
23
 
24
24
  export const DEFAULT_ON_SWITCH: OnSwitchAction = "ask-before-pull";
25
25
 
26
26
  export function normalizeOnSwitch(value: unknown): OnSwitchAction {
27
- if (value === "ask-before-pull" || value === "pull-after-switch" || value === "switch-only") {
28
- return value;
29
- }
30
- throw new Error(
31
- 'Invalid pi-sync settings: onSwitch must be "ask-before-pull", "pull-after-switch", or "switch-only".',
32
- );
27
+ if (value === "ask-before-pull" || value === "pull-after-switch" || value === "switch-only") {
28
+ return value;
29
+ }
30
+ throw new Error(
31
+ 'Invalid pi-sync settings: onSwitch must be "ask-before-pull", "pull-after-switch", or "switch-only".',
32
+ );
33
33
  }
34
34
 
35
35
  export function validateSettingsDocument(value: Record<string, unknown>): PiSyncSettingsV3 {
36
- if (value.version !== 3) {
37
- throw new Error(
38
- `Unsupported pi-sync settings: version 3 is required. Keep the existing file for recovery, then create a new version 3 ${path.basename(localConfigPath())}; pi-sync will not migrate or overwrite old settings.`,
39
- );
40
- }
41
- rejectLegacyFields(
42
- value,
43
- [
44
- "profiles",
45
- "targets",
46
- "activeTarget",
47
- "targetSwitchAction",
48
- "endpoint",
49
- "bucket",
50
- "region",
51
- "accessKeyId",
52
- "secretAccessKey",
53
- "sessionToken",
54
- "profile",
55
- "prefix",
56
- "autoSync",
57
- "syncFiles",
58
- "syncSessions",
59
- "extraFiles",
60
- ],
61
- "top level",
62
- );
63
- normalizeOnSwitch(value.onSwitch);
64
- if (value.skipSecretScan !== undefined && typeof value.skipSecretScan !== "boolean") {
65
- throw new Error("Invalid pi-sync settings: skipSecretScan must be boolean.");
66
- }
67
- const storageConnections = requireNamedObjectMap(
68
- value.storageConnections,
69
- "storageConnections",
70
- "storage connection",
71
- );
72
- const syncSetups = requireNamedObjectMap(value.syncSetups, "syncSetups", "sync setup");
73
- for (const name of Object.keys(storageConnections)) {
74
- validateStorageConnection(
75
- name,
76
- requireOwnObject(storageConnections, name, "storage connection") as Record<string, unknown>,
77
- );
78
- }
79
- for (const name of Object.keys(syncSetups)) {
80
- validateSyncSetup(
81
- name,
82
- requireOwnObject(syncSetups, name, "sync setup") as Record<string, unknown>,
83
- storageConnections,
84
- );
85
- }
86
- const names = Object.keys(syncSetups);
87
- const activeSyncSetup = optionalCanonicalReference(value.activeSyncSetup, "activeSyncSetup");
88
- if (names.length === 0) {
89
- if (activeSyncSetup !== undefined) {
90
- throw new Error("Invalid pi-sync settings: empty syncSetups cannot have activeSyncSetup.");
91
- }
92
- } else if (!activeSyncSetup || !Object.hasOwn(syncSetups, activeSyncSetup)) {
93
- throw new Error(
94
- "Invalid pi-sync settings: activeSyncSetup must reference an existing own-property sync setup.",
95
- );
96
- }
97
- validateUniqueRemoteSyncSetups(syncSetups, storageConnections);
98
- return value as PiSyncSettingsV3;
36
+ if (value.version !== 3) {
37
+ throw new Error(
38
+ `Unsupported pi-sync settings: version 3 is required. Keep the existing file for recovery, then create a new version 3 ${path.basename(localConfigPath())}; pi-sync will not migrate or overwrite old settings.`,
39
+ );
40
+ }
41
+ rejectLegacyFields(
42
+ value,
43
+ [
44
+ "profiles",
45
+ "targets",
46
+ "activeTarget",
47
+ "targetSwitchAction",
48
+ "endpoint",
49
+ "bucket",
50
+ "region",
51
+ "accessKeyId",
52
+ "secretAccessKey",
53
+ "sessionToken",
54
+ "profile",
55
+ "prefix",
56
+ "autoSync",
57
+ "syncFiles",
58
+ "syncSessions",
59
+ "extraFiles",
60
+ ],
61
+ "top level",
62
+ );
63
+ normalizeOnSwitch(value.onSwitch);
64
+ if (value.skipSecretScan !== undefined && typeof value.skipSecretScan !== "boolean") {
65
+ throw new Error("Invalid pi-sync settings: skipSecretScan must be boolean.");
66
+ }
67
+ if (value.showStatus !== undefined && typeof value.showStatus !== "boolean") {
68
+ throw new Error("Invalid pi-sync settings: showStatus must be boolean.");
69
+ }
70
+ const storageConnections = requireNamedObjectMap(
71
+ value.storageConnections,
72
+ "storageConnections",
73
+ "storage connection",
74
+ );
75
+ const syncSetups = requireNamedObjectMap(value.syncSetups, "syncSetups", "sync setup");
76
+ for (const name of Object.keys(storageConnections)) {
77
+ validateStorageConnection(
78
+ name,
79
+ requireOwnObject(storageConnections, name, "storage connection") as Record<string, unknown>,
80
+ );
81
+ }
82
+ for (const name of Object.keys(syncSetups)) {
83
+ validateSyncSetup(
84
+ name,
85
+ requireOwnObject(syncSetups, name, "sync setup") as Record<string, unknown>,
86
+ storageConnections,
87
+ );
88
+ }
89
+ const names = Object.keys(syncSetups);
90
+ const activeSyncSetup = optionalCanonicalReference(value.activeSyncSetup, "activeSyncSetup");
91
+ if (names.length === 0) {
92
+ if (activeSyncSetup !== undefined) {
93
+ throw new Error("Invalid pi-sync settings: empty syncSetups cannot have activeSyncSetup.");
94
+ }
95
+ } else if (!activeSyncSetup || !Object.hasOwn(syncSetups, activeSyncSetup)) {
96
+ throw new Error("Invalid pi-sync settings: activeSyncSetup must reference an existing own-property sync setup.");
97
+ }
98
+ validateUniqueRemoteSyncSetups(syncSetups, storageConnections);
99
+ return value as PiSyncSettingsV3;
99
100
  }
100
101
 
101
102
  function validateStorageConnection(name: string, value: Record<string, unknown>) {
102
- rejectLegacyFields(
103
- value,
104
- ["kind", "accessKeyId", "secretAccessKey", "sessionToken", "username", "password"],
105
- `storage connection “${name}”`,
106
- );
107
- const type = requiredString(value.type, `storage connection “${name}” type`);
108
- if (type !== "s3" && type !== "git" && type !== "webdav") {
109
- throw new Error(`Invalid pi-sync settings: storage connection “${name}” has unsupported type.`);
110
- }
111
- const known = ["endpoint", "region", "remote", "url", "credentials"];
112
- const allowed =
113
- type === "s3"
114
- ? new Set(["endpoint", "region", "credentials"])
115
- : type === "git"
116
- ? new Set(["remote"])
117
- : new Set(["url", "credentials"]);
118
- if (known.some((field) => Object.hasOwn(value, field) && !allowed.has(field))) {
119
- throw new Error(
120
- `Invalid pi-sync settings: ${type.toUpperCase()} storage connection “${name}” mixes backend fields.`,
121
- );
122
- }
123
- if (type === "git") {
124
- if (!normalizeGitRemote(requiredString(value.remote, `Git remote for “${name}”`))) {
125
- throw new Error(`Invalid pi-sync settings: Git remote for “${name}” is required.`);
126
- }
127
- return;
128
- }
129
- const credentials = requireRecord(
130
- value.credentials,
131
- `credentials for storage connection “${name}”`,
132
- );
133
- if (type === "webdav") {
134
- normalizeWebDavUrl(requiredString(value.url, `WebDAV URL for “${name}”`));
135
- const username = requiredString(credentials.username, `WebDAV username for “${name}”`);
136
- const password = requiredSecret(credentials.password, `WebDAV password for “${name}”`);
137
- if (
138
- ["accessKeyId", "secretAccessKey", "sessionToken"].some((field) =>
139
- Object.hasOwn(credentials, field),
140
- )
141
- ) {
142
- throw new Error(`Invalid pi-sync settings: WebDAV credentials for “${name}” mix fields.`);
143
- }
144
- validateWebDavCredentials(username, password);
145
- return;
146
- }
147
- normalizeS3Endpoint(requiredString(value.endpoint, `S3 endpoint for “${name}”`));
148
- requiredString(value.region, `S3 region for “${name}”`);
149
- requiredString(credentials.accessKeyId, `S3 access key id for “${name}”`);
150
- requiredSecret(credentials.secretAccessKey, `S3 secret access key for “${name}”`);
151
- optionalString(credentials.sessionToken, `S3 session token for “${name}”`);
152
- if (["username", "password"].some((field) => Object.hasOwn(credentials, field))) {
153
- throw new Error(`Invalid pi-sync settings: S3 credentials for “${name}” mix fields.`);
154
- }
103
+ rejectLegacyFields(
104
+ value,
105
+ ["kind", "accessKeyId", "secretAccessKey", "sessionToken", "username", "password"],
106
+ `storage connection “${name}”`,
107
+ );
108
+ const type = requiredString(value.type, `storage connection “${name}” type`);
109
+ if (type !== "s3" && type !== "git" && type !== "webdav") {
110
+ throw new Error(`Invalid pi-sync settings: storage connection “${name}” has unsupported type.`);
111
+ }
112
+ const known = ["endpoint", "region", "remote", "url", "credentials"];
113
+ const allowed =
114
+ type === "s3"
115
+ ? new Set(["endpoint", "region", "credentials"])
116
+ : type === "git"
117
+ ? new Set(["remote"])
118
+ : new Set(["url", "credentials"]);
119
+ if (known.some((field) => Object.hasOwn(value, field) && !allowed.has(field))) {
120
+ throw new Error(
121
+ `Invalid pi-sync settings: ${type.toUpperCase()} storage connection “${name}” mixes backend fields.`,
122
+ );
123
+ }
124
+ if (type === "git") {
125
+ if (!normalizeGitRemote(requiredString(value.remote, `Git remote for “${name}”`))) {
126
+ throw new Error(`Invalid pi-sync settings: Git remote for “${name}” is required.`);
127
+ }
128
+ return;
129
+ }
130
+ const credentials = requireRecord(value.credentials, `credentials for storage connection “${name}”`);
131
+ if (type === "webdav") {
132
+ normalizeWebDavUrl(requiredString(value.url, `WebDAV URL for “${name}”`));
133
+ const username = requiredString(credentials.username, `WebDAV username for “${name}”`);
134
+ const password = requiredSecret(credentials.password, `WebDAV password for “${name}”`);
135
+ if (["accessKeyId", "secretAccessKey", "sessionToken"].some((field) => Object.hasOwn(credentials, field))) {
136
+ throw new Error(`Invalid pi-sync settings: WebDAV credentials for “${name}” mix fields.`);
137
+ }
138
+ validateWebDavCredentials(username, password);
139
+ return;
140
+ }
141
+ normalizeS3Endpoint(requiredString(value.endpoint, `S3 endpoint for “${name}”`));
142
+ requiredString(value.region, `S3 region for “${name}”`);
143
+ requiredString(credentials.accessKeyId, `S3 access key id for “${name}”`);
144
+ requiredSecret(credentials.secretAccessKey, `S3 secret access key for “${name}”`);
145
+ optionalString(credentials.sessionToken, `S3 session token for “${name}”`);
146
+ if (["username", "password"].some((field) => Object.hasOwn(credentials, field))) {
147
+ throw new Error(`Invalid pi-sync settings: S3 credentials for “${name}” mix fields.`);
148
+ }
155
149
  }
156
150
 
157
- function validateSyncSetup(
158
- name: string,
159
- value: Record<string, unknown>,
160
- connections: Record<string, unknown>,
161
- ) {
162
- rejectLegacyFields(
163
- value,
164
- [
165
- "profile",
166
- "bucket",
167
- "branch",
168
- "path",
169
- "prefix",
170
- "directory",
171
- "namespace",
172
- "autoSync",
173
- "syncFiles",
174
- "syncSessions",
175
- "extraFiles",
176
- ],
177
- `sync setup “${name}”`,
178
- );
179
- const storage = requireRecord(value.storage, `storage for sync setup “${name}”`);
180
- const sync = requireRecord(value.sync, `sync policy for sync setup “${name}”`);
181
- rejectLegacyFields(
182
- storage,
183
- ["profile", "prefix", "directory", "namespace"],
184
- `storage for sync setup “${name}”`,
185
- );
186
- rejectLegacyFields(
187
- sync,
188
- ["autoSync", "syncFiles", "syncSessions", "extraFiles"],
189
- `sync policy for sync setup “${name}”`,
190
- );
191
- const connectionName = requiredCanonicalReference(
192
- storage.connection,
193
- `storage connection reference for sync setup “${name}”`,
194
- );
195
- validateConfigName(connectionName, "storage connection reference");
196
- const connection = ownObject<Record<string, unknown>>(connections, connectionName);
197
- if (!connection) {
198
- throw new Error(
199
- `Invalid pi-sync settings: sync setup “${name}” references missing storage connection “${connectionName}”.`,
200
- );
201
- }
202
- const type = connection.type;
203
- normalizeStoragePath(requiredString(storage.path, `storage path for sync setup “${name}”`));
204
- if (type === "s3") {
205
- normalizeS3Bucket(requiredString(storage.bucket, `S3 bucket for sync setup “${name}”`));
206
- if (Object.hasOwn(storage, "branch")) mixedSetupError("S3", name);
207
- } else if (type === "git") {
208
- normalizeGitBranch(requiredString(storage.branch, `Git branch for sync setup “${name}”`));
209
- if (Object.hasOwn(storage, "bucket")) mixedSetupError("Git", name);
210
- } else if (type === "webdav") {
211
- if (Object.hasOwn(storage, "bucket") || Object.hasOwn(storage, "branch")) {
212
- mixedSetupError("WebDAV", name);
213
- }
214
- }
215
- if (!Object.hasOwn(sync, "include")) {
216
- throw new Error(`Invalid pi-sync settings: sync setup “${name}” is missing sync.include.`);
217
- }
218
- normalizeSyncInclude(sync.include);
219
- if (typeof sync.automatic !== "boolean") {
220
- throw new Error(
221
- `Invalid pi-sync settings: sync setup “${name}” sync.automatic must be boolean.`,
222
- );
223
- }
151
+ function validateSyncSetup(name: string, value: Record<string, unknown>, connections: Record<string, unknown>) {
152
+ rejectLegacyFields(
153
+ value,
154
+ [
155
+ "profile",
156
+ "bucket",
157
+ "branch",
158
+ "path",
159
+ "prefix",
160
+ "directory",
161
+ "namespace",
162
+ "autoSync",
163
+ "syncFiles",
164
+ "syncSessions",
165
+ "extraFiles",
166
+ ],
167
+ `sync setup “${name}”`,
168
+ );
169
+ const storage = requireRecord(value.storage, `storage for sync setup “${name}”`);
170
+ const sync = requireRecord(value.sync, `sync policy for sync setup “${name}”`);
171
+ rejectLegacyFields(storage, ["profile", "prefix", "directory", "namespace"], `storage for sync setup “${name}”`);
172
+ rejectLegacyFields(
173
+ sync,
174
+ ["autoSync", "syncFiles", "syncSessions", "extraFiles"],
175
+ `sync policy for sync setup “${name}”`,
176
+ );
177
+ const connectionName = requiredCanonicalReference(
178
+ storage.connection,
179
+ `storage connection reference for sync setup “${name}”`,
180
+ );
181
+ validateConfigName(connectionName, "storage connection reference");
182
+ const connection = ownObject<Record<string, unknown>>(connections, connectionName);
183
+ if (!connection) {
184
+ throw new Error(
185
+ `Invalid pi-sync settings: sync setup “${name}” references missing storage connection “${connectionName}”.`,
186
+ );
187
+ }
188
+ const type = connection.type;
189
+ normalizeStoragePath(requiredString(storage.path, `storage path for sync setup “${name}”`));
190
+ if (type === "s3") {
191
+ normalizeS3Bucket(requiredString(storage.bucket, `S3 bucket for sync setup “${name}”`));
192
+ if (Object.hasOwn(storage, "branch")) mixedSetupError("S3", name);
193
+ } else if (type === "git") {
194
+ normalizeGitBranch(requiredString(storage.branch, `Git branch for sync setup “${name}”`));
195
+ if (Object.hasOwn(storage, "bucket")) mixedSetupError("Git", name);
196
+ } else if (type === "webdav") {
197
+ if (Object.hasOwn(storage, "bucket") || Object.hasOwn(storage, "branch")) {
198
+ mixedSetupError("WebDAV", name);
199
+ }
200
+ }
201
+ if (!Object.hasOwn(sync, "include")) {
202
+ throw new Error(`Invalid pi-sync settings: sync setup “${name}” is missing sync.include.`);
203
+ }
204
+ normalizeSyncInclude(sync.include);
205
+ if (typeof sync.automatic !== "boolean") {
206
+ throw new Error(`Invalid pi-sync settings: sync setup “${name}” sync.automatic must be boolean.`);
207
+ }
224
208
  }
225
209
 
226
210
  function mixedSetupError(type: string, name: string): never {
227
- throw new Error(`Invalid pi-sync settings: ${type} sync setup “${name}” mixes backend fields.`);
211
+ throw new Error(`Invalid pi-sync settings: ${type} sync setup “${name}” mixes backend fields.`);
228
212
  }
229
213
 
230
- export function validateUniqueRemoteSyncSetups(
231
- setups: Record<string, unknown>,
232
- connections: Record<string, unknown>,
233
- ) {
234
- const identities = new Map<string, string>();
235
- for (const name of Object.keys(setups)) {
236
- const setup = requireOwnObject(setups, name, "sync setup") as unknown as SyncSetupSettings;
237
- const connection = requireOwnObject(
238
- connections,
239
- setup.storage.connection,
240
- "storage connection",
241
- ) as unknown as StorageConnectionSettings;
242
- const identity = effectiveSyncSetupRemoteIdentity(setup, connection);
243
- const existing = identities.get(identity);
244
- if (existing) {
245
- throw new Error(
246
- `Invalid pi-sync settings: sync setups “${existing}” and “${name}” use the same normalized remote location.`,
247
- );
248
- }
249
- identities.set(identity, name);
250
- }
214
+ export function validateUniqueRemoteSyncSetups(setups: Record<string, unknown>, connections: Record<string, unknown>) {
215
+ const identities = new Map<string, string>();
216
+ for (const name of Object.keys(setups)) {
217
+ const setup = requireOwnObject(setups, name, "sync setup") as unknown as SyncSetupSettings;
218
+ const connection = requireOwnObject(
219
+ connections,
220
+ setup.storage.connection,
221
+ "storage connection",
222
+ ) as unknown as StorageConnectionSettings;
223
+ const identity = effectiveSyncSetupRemoteIdentity(setup, connection);
224
+ const existing = identities.get(identity);
225
+ if (existing) {
226
+ throw new Error(
227
+ `Invalid pi-sync settings: sync setups “${existing}” and “${name}” use the same normalized remote location.`,
228
+ );
229
+ }
230
+ identities.set(identity, name);
231
+ }
251
232
  }
252
233
 
253
- export function effectiveSyncSetupRemoteIdentity(
254
- setup: SyncSetupSettings,
255
- connection: StorageConnectionSettings,
256
- ) {
257
- const storagePath = normalizeStoragePath(setup.storage.path);
258
- if (connection.type === "git") {
259
- return JSON.stringify([
260
- "git",
261
- normalizeGitRemoteIdentity(connection.remote),
262
- normalizeGitBranch(setup.storage.branch),
263
- normalizeGitDirectory(storagePath),
264
- ]);
265
- }
266
- if (connection.type === "webdav") {
267
- return JSON.stringify([
268
- "webdav",
269
- normalizeWebDavIdentityUrl(connection.url),
270
- connection.credentials.username.trim(),
271
- normalizeWebDavPath(storagePath),
272
- ]);
273
- }
274
- return JSON.stringify([
275
- "s3",
276
- normalizeEndpointIdentity(connection.endpoint),
277
- normalizeS3Bucket(setup.storage.bucket),
278
- storagePath,
279
- ]);
234
+ export function effectiveSyncSetupRemoteIdentity(setup: SyncSetupSettings, connection: StorageConnectionSettings) {
235
+ const storagePath = normalizeStoragePath(setup.storage.path);
236
+ if (connection.type === "git") {
237
+ return JSON.stringify([
238
+ "git",
239
+ normalizeGitRemoteIdentity(connection.remote),
240
+ normalizeGitBranch(setup.storage.branch),
241
+ normalizeGitDirectory(storagePath),
242
+ ]);
243
+ }
244
+ if (connection.type === "webdav") {
245
+ return JSON.stringify([
246
+ "webdav",
247
+ normalizeWebDavIdentityUrl(connection.url),
248
+ connection.credentials.username.trim(),
249
+ normalizeWebDavPath(storagePath),
250
+ ]);
251
+ }
252
+ return JSON.stringify([
253
+ "s3",
254
+ normalizeEndpointIdentity(connection.endpoint),
255
+ normalizeS3Bucket(setup.storage.bucket),
256
+ storagePath,
257
+ ]);
280
258
  }
281
259
 
282
- function rejectLegacyFields(
283
- value: Record<string, unknown>,
284
- fields: readonly string[],
285
- context: string,
286
- ) {
287
- const field = fields.find((candidate) => Object.hasOwn(value, candidate));
288
- if (field) {
289
- throw new Error(
290
- `Invalid pi-sync settings: ${context} contains unsupported version 1/2 field “${field}”.`,
291
- );
292
- }
260
+ function rejectLegacyFields(value: Record<string, unknown>, fields: readonly string[], context: string) {
261
+ const field = fields.find((candidate) => Object.hasOwn(value, candidate));
262
+ if (field) {
263
+ throw new Error(`Invalid pi-sync settings: ${context} contains unsupported version 1/2 field “${field}”.`);
264
+ }
293
265
  }
294
266
 
295
267
  function requireNamedObjectMap(value: unknown, field: string, itemLabel: string) {
296
- const result = requireRecord(value, field);
297
- for (const name of Object.keys(result)) validateConfigName(name, itemLabel);
298
- return result;
268
+ const result = requireRecord(value, field);
269
+ for (const name of Object.keys(result)) validateConfigName(name, itemLabel);
270
+ return result;
299
271
  }
300
272
 
301
273
  function requireOwnObject(value: Record<string, unknown>, key: string, label: string) {
302
- const item = ownObject(value, key);
303
- if (!item) throw new Error(`Invalid pi-sync settings: ${label} “${key}” must be an object.`);
304
- return item;
274
+ const item = ownObject(value, key);
275
+ if (!item) throw new Error(`Invalid pi-sync settings: ${label} “${key}” must be an object.`);
276
+ return item;
305
277
  }
306
278
 
307
- export function ownObject<T extends object>(
308
- value: Record<string, unknown>,
309
- key: string,
310
- ): T | undefined {
311
- if (!Object.hasOwn(value, key)) return undefined;
312
- const item = value[key];
313
- return item && typeof item === "object" && !Array.isArray(item) ? (item as T) : undefined;
279
+ export function ownObject<T extends object>(value: Record<string, unknown>, key: string): T | undefined {
280
+ if (!Object.hasOwn(value, key)) return undefined;
281
+ const item = value[key];
282
+ return item && typeof item === "object" && !Array.isArray(item) ? (item as T) : undefined;
314
283
  }
315
284
 
316
285
  function requireRecord(value: unknown, field: string): Record<string, unknown> {
317
- if (!value || typeof value !== "object" || Array.isArray(value)) {
318
- throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
319
- }
320
- return value as Record<string, unknown>;
286
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
287
+ throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
288
+ }
289
+ return value as Record<string, unknown>;
321
290
  }
322
291
 
323
292
  export function validateConfigName(value: string, field: string) {
324
- if (
325
- !value.trim() ||
326
- value !== value.trim() ||
327
- value.length > 100 ||
328
- value === "__proto__" ||
329
- value === "prototype" ||
330
- value === "constructor" ||
331
- hasControlCharacter(value)
332
- ) {
333
- throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
334
- }
293
+ if (
294
+ !value.trim() ||
295
+ value !== value.trim() ||
296
+ value.length > 100 ||
297
+ value === "__proto__" ||
298
+ value === "prototype" ||
299
+ value === "constructor" ||
300
+ hasControlCharacter(value)
301
+ ) {
302
+ throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
303
+ }
335
304
  }
336
305
 
337
306
  export function requiredString(value: unknown, field: string) {
338
- if (typeof value !== "string" || !value.trim() || hasControlCharacter(value)) {
339
- throw new Error(`Invalid pi-sync settings: ${field} must be a non-empty string.`);
340
- }
341
- return value.trim();
307
+ if (typeof value !== "string" || !value.trim() || hasControlCharacter(value)) {
308
+ throw new Error(`Invalid pi-sync settings: ${field} must be a non-empty string.`);
309
+ }
310
+ return value.trim();
342
311
  }
343
312
 
344
313
  function requiredCanonicalReference(value: unknown, field: string) {
345
- const normalized = requiredString(value, field);
346
- if (value !== normalized) {
347
- throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
348
- }
349
- return normalized;
314
+ const normalized = requiredString(value, field);
315
+ if (value !== normalized) {
316
+ throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
317
+ }
318
+ return normalized;
350
319
  }
351
320
 
352
321
  function optionalCanonicalReference(value: unknown, field: string) {
353
- const normalized = optionalString(value, field);
354
- if (normalized !== undefined && value !== normalized) {
355
- throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
356
- }
357
- return normalized;
322
+ const normalized = optionalString(value, field);
323
+ if (normalized !== undefined && value !== normalized) {
324
+ throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
325
+ }
326
+ return normalized;
358
327
  }
359
328
 
360
329
  function requiredSecret(value: unknown, field: string) {
361
- if (typeof value !== "string" || !value || hasControlCharacter(value)) {
362
- throw new Error(`Invalid pi-sync settings: ${field} must be configured.`);
363
- }
364
- return value;
330
+ if (typeof value !== "string" || !value || hasControlCharacter(value)) {
331
+ throw new Error(`Invalid pi-sync settings: ${field} must be configured.`);
332
+ }
333
+ return value;
365
334
  }
366
335
 
367
336
  export function optionalString(value: unknown, field: string) {
368
- if (value === undefined) return undefined;
369
- if (typeof value !== "string" || hasControlCharacter(value)) {
370
- throw new Error(`Invalid pi-sync settings: ${field} must be a string.`);
371
- }
372
- return value.trim() || undefined;
337
+ if (value === undefined) return undefined;
338
+ if (typeof value !== "string" || hasControlCharacter(value)) {
339
+ throw new Error(`Invalid pi-sync settings: ${field} must be a string.`);
340
+ }
341
+ return value.trim() || undefined;
373
342
  }
374
343
 
375
344
  export function normalizeStoragePath(value: string) {
376
- if (value.trim() === "." || value.trim() === "./") return "./";
377
- const normalized = value.trim().replace(/^\/+|\/+$/gu, "");
378
- if (
379
- !normalized ||
380
- normalized.length > 1024 ||
381
- normalized.startsWith("-") ||
382
- normalized.includes("\\") ||
383
- hasControlCharacter(normalized) ||
384
- normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
385
- ) {
386
- throw new Error("Invalid pi-sync settings: storage.path must be a safe relative path.");
387
- }
388
- return normalized;
345
+ if (value.trim() === "." || value.trim() === "./") return "./";
346
+ const normalized = value.trim().replace(/^\/+|\/+$/gu, "");
347
+ if (
348
+ !normalized ||
349
+ normalized.length > 1024 ||
350
+ normalized.startsWith("-") ||
351
+ normalized.includes("\\") ||
352
+ hasControlCharacter(normalized) ||
353
+ normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
354
+ ) {
355
+ throw new Error("Invalid pi-sync settings: storage.path must be a safe relative path.");
356
+ }
357
+ return normalized;
389
358
  }
390
359
 
391
360
  export function normalizeS3Endpoint(value: string) {
392
- let url: URL;
393
- try {
394
- url = new URL(value.trim());
395
- } catch {
396
- throw new Error("Invalid pi-sync S3 endpoint.");
397
- }
398
- const loopback =
399
- url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
400
- if (
401
- (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
402
- url.username ||
403
- url.password ||
404
- url.search ||
405
- url.hash
406
- ) {
407
- throw new Error("Invalid pi-sync S3 endpoint: HTTPS is required except for loopback.");
408
- }
409
- url.pathname = url.pathname.replace(/\/+$/gu, "");
410
- return url.toString().replace(/\/$/u, "");
361
+ let url: URL;
362
+ try {
363
+ url = new URL(value.trim());
364
+ } catch {
365
+ throw new Error("Invalid pi-sync S3 endpoint.");
366
+ }
367
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
368
+ if (
369
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
370
+ url.username ||
371
+ url.password ||
372
+ url.search ||
373
+ url.hash
374
+ ) {
375
+ throw new Error("Invalid pi-sync S3 endpoint: HTTPS is required except for loopback.");
376
+ }
377
+ url.pathname = url.pathname.replace(/\/+$/gu, "");
378
+ return url.toString().replace(/\/$/u, "");
411
379
  }
412
380
 
413
381
  export function normalizeS3Bucket(value: string | undefined) {
414
- const bucket = requiredString(value, "S3 bucket");
415
- if (bucket.includes("/") || bucket.includes("\\") || bucket.startsWith("-")) {
416
- throw new Error("Invalid pi-sync S3 bucket.");
417
- }
418
- return bucket;
382
+ const bucket = requiredString(value, "S3 bucket");
383
+ if (bucket.includes("/") || bucket.includes("\\") || bucket.startsWith("-")) {
384
+ throw new Error("Invalid pi-sync S3 bucket.");
385
+ }
386
+ return bucket;
419
387
  }
420
388
 
421
389
  function hasControlCharacter(value: string) {
422
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Stored settings cannot contain controls.
423
- return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
390
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Stored settings cannot contain controls.
391
+ return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
424
392
  }
425
393
 
426
394
  export function isCloudflareR2Endpoint(endpoint: string | undefined) {
427
- const value = endpoint?.trim();
428
- if (!value) return false;
429
- try {
430
- const hostname = new URL(value).hostname.toLowerCase();
431
- return (
432
- hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
433
- );
434
- } catch {
435
- return false;
436
- }
395
+ const value = endpoint?.trim();
396
+ if (!value) return false;
397
+ try {
398
+ const hostname = new URL(value).hostname.toLowerCase();
399
+ return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
400
+ } catch {
401
+ return false;
402
+ }
437
403
  }
438
404
 
439
405
  export function ownRecord(value: unknown): Record<string, unknown> | undefined {
440
- return value && typeof value === "object" && !Array.isArray(value)
441
- ? (value as Record<string, unknown>)
442
- : undefined;
406
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
443
407
  }