@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,15 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
- mkdir,
4
- mkdirSync,
5
- realpath,
6
- realpathSync,
7
- rmdir,
8
- rmdirSync,
9
- stat,
10
- statSync,
11
- utimes,
12
- utimesSync,
3
+ mkdir,
4
+ mkdirSync,
5
+ realpath,
6
+ realpathSync,
7
+ rmdir,
8
+ rmdirSync,
9
+ stat,
10
+ statSync,
11
+ utimes,
12
+ utimesSync,
13
13
  } from "node:fs";
14
14
  import fs from "node:fs/promises";
15
15
  import path from "node:path";
@@ -23,16 +23,16 @@ const legacyPresenceNoticed = new Set<string>();
23
23
  const CONFIG_LOCK_STALE_MS = 30_000;
24
24
  const CONFIG_LOCK_UPDATE_MS = 10_000;
25
25
  const LOCKFILE_FS_ADAPTER = {
26
- mkdir,
27
- mkdirSync,
28
- realpath,
29
- realpathSync,
30
- rmdir,
31
- rmdirSync,
32
- stat,
33
- statSync,
34
- utimes,
35
- utimesSync,
26
+ mkdir,
27
+ mkdirSync,
28
+ realpath,
29
+ realpathSync,
30
+ rmdir,
31
+ rmdirSync,
32
+ stat,
33
+ statSync,
34
+ utimes,
35
+ utimesSync,
36
36
  };
37
37
 
38
38
  type PublishFile = (source: string, destination: string) => Promise<void>;
@@ -44,596 +44,563 @@ let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
44
44
 
45
45
  export type FileIdentity = { dev: number; ino: number };
46
46
 
47
- export async function withConfigFilePublicationForTest<T>(
48
- publish: PublishFile,
49
- run: () => Promise<T>,
50
- ): Promise<T> {
51
- const previous = publishConfigFile;
52
- publishConfigFile = publish;
53
- try {
54
- return await run();
55
- } finally {
56
- publishConfigFile = previous;
57
- }
47
+ export async function withConfigFilePublicationForTest<T>(publish: PublishFile, run: () => Promise<T>): Promise<T> {
48
+ const previous = publishConfigFile;
49
+ publishConfigFile = publish;
50
+ try {
51
+ return await run();
52
+ } finally {
53
+ publishConfigFile = previous;
54
+ }
58
55
  }
59
56
 
60
57
  export async function withConfigPublicationReadyHookForTest<T>(
61
- hook: () => Promise<void>,
62
- run: () => Promise<T>,
58
+ hook: () => Promise<void>,
59
+ run: () => Promise<T>,
63
60
  ): Promise<T> {
64
- const previous = beforeConfigPublicationHook;
65
- beforeConfigPublicationHook = hook;
66
- try {
67
- return await run();
68
- } finally {
69
- beforeConfigPublicationHook = previous;
70
- }
61
+ const previous = beforeConfigPublicationHook;
62
+ beforeConfigPublicationHook = hook;
63
+ try {
64
+ return await run();
65
+ } finally {
66
+ beforeConfigPublicationHook = previous;
67
+ }
71
68
  }
72
69
 
73
70
  export async function withMissingConfigReadProbeHookForTest<T>(
74
- hook: () => Promise<void>,
75
- run: () => Promise<T>,
71
+ hook: () => Promise<void>,
72
+ run: () => Promise<T>,
76
73
  ): Promise<T> {
77
- const previous = afterMissingConfigReadProbeHook;
78
- afterMissingConfigReadProbeHook = hook;
79
- try {
80
- return await run();
81
- } finally {
82
- afterMissingConfigReadProbeHook = previous;
83
- }
74
+ const previous = afterMissingConfigReadProbeHook;
75
+ afterMissingConfigReadProbeHook = hook;
76
+ try {
77
+ return await run();
78
+ } finally {
79
+ afterMissingConfigReadProbeHook = previous;
80
+ }
84
81
  }
85
82
 
86
83
  export async function withConfigReplacementInstalledHookForTest<T>(
87
- hook: () => Promise<void>,
88
- run: () => Promise<T>,
84
+ hook: () => Promise<void>,
85
+ run: () => Promise<T>,
89
86
  ): Promise<T> {
90
- const previous = afterReplacementInstalledHook;
91
- afterReplacementInstalledHook = hook;
92
- try {
93
- return await run();
94
- } finally {
95
- afterReplacementInstalledHook = previous;
96
- }
87
+ const previous = afterReplacementInstalledHook;
88
+ afterReplacementInstalledHook = hook;
89
+ try {
90
+ return await run();
91
+ } finally {
92
+ afterReplacementInstalledHook = previous;
93
+ }
97
94
  }
98
95
 
99
96
  export async function withConfigQuarantinedHookForTest<T>(
100
- hook: () => Promise<void>,
101
- run: () => Promise<T>,
97
+ hook: () => Promise<void>,
98
+ run: () => Promise<T>,
102
99
  ): Promise<T> {
103
- const previous = afterConfigQuarantinedHook;
104
- afterConfigQuarantinedHook = hook;
105
- try {
106
- return await run();
107
- } finally {
108
- afterConfigQuarantinedHook = previous;
109
- }
100
+ const previous = afterConfigQuarantinedHook;
101
+ afterConfigQuarantinedHook = hook;
102
+ try {
103
+ return await run();
104
+ } finally {
105
+ afterConfigQuarantinedHook = previous;
106
+ }
110
107
  }
111
108
 
112
109
  type ConfigSnapshot = {
113
- bytes: Buffer;
114
- identity: FileIdentity;
115
- parsed: Record<string, unknown>;
110
+ bytes: Buffer;
111
+ identity: FileIdentity;
112
+ parsed: Record<string, unknown>;
116
113
  };
117
114
 
118
115
  export type LocalConfigDocument = ConfigSnapshot & { path: string };
119
116
 
120
117
  export function localConfigPath() {
121
- return path.join(getAgentDir(), CONFIG_FILE_NAME);
118
+ return path.join(getAgentDir(), CONFIG_FILE_NAME);
122
119
  }
123
120
 
124
121
  export function legacyLocalConfigPath() {
125
- return path.join(getAgentDir(), LEGACY_CONFIG_FILE_NAME);
122
+ return path.join(getAgentDir(), LEGACY_CONFIG_FILE_NAME);
126
123
  }
127
124
 
128
125
  export async function activeLocalConfigPath() {
129
- const canonicalPath = localConfigPath();
130
- const legacyPath = legacyLocalConfigPath();
131
- return withLocalConfigReadLockIfNeeded(async () => {
132
- if (await pathExists(canonicalPath)) return canonicalPath;
133
- return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
134
- });
126
+ const canonicalPath = localConfigPath();
127
+ const legacyPath = legacyLocalConfigPath();
128
+ return withLocalConfigReadLockIfNeeded(async () => {
129
+ if (await pathExists(canonicalPath)) return canonicalPath;
130
+ return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
131
+ });
135
132
  }
136
133
 
137
134
  export function consumeLocalConfigMigrationNotice() {
138
- const configPath = localConfigPath();
139
- const notice = configMigrationNotices.get(configPath);
140
- configMigrationNotices.delete(configPath);
141
- return notice;
135
+ const configPath = localConfigPath();
136
+ const notice = configMigrationNotices.get(configPath);
137
+ configMigrationNotices.delete(configPath);
138
+ return notice;
142
139
  }
143
140
 
144
- export async function readActiveLocalConfigDocumentForRepair(): Promise<
145
- LocalConfigDocument | undefined
146
- > {
147
- const canonicalPath = localConfigPath();
148
- const legacyPath = legacyLocalConfigPath();
149
- return withLocalConfigReadLockIfNeeded(async () => {
150
- const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
151
- const snapshot = await readConfigSnapshotIfExists(filePath);
152
- return snapshot ? { path: filePath, ...snapshot } : undefined;
153
- });
141
+ export async function readActiveLocalConfigDocumentForRepair(): Promise<LocalConfigDocument | undefined> {
142
+ const canonicalPath = localConfigPath();
143
+ const legacyPath = legacyLocalConfigPath();
144
+ return withLocalConfigReadLockIfNeeded(async () => {
145
+ const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
146
+ const snapshot = await readConfigSnapshotIfExists(filePath);
147
+ return snapshot ? { path: filePath, ...snapshot } : undefined;
148
+ });
154
149
  }
155
150
 
156
151
  export async function readMigratingLocalConfigDocument(
157
- validateForMigration: (settings: Record<string, unknown>) => void,
152
+ validateForMigration: (settings: Record<string, unknown>) => void,
158
153
  ): Promise<LocalConfigDocument | undefined> {
159
- return withLocalConfigReadLockIfNeeded(async () => {
160
- const configPath = await prepareLocalConfigPath(validateForMigration);
161
- const snapshot = await readConfigSnapshotIfExists(configPath);
162
- return snapshot ? { path: configPath, ...snapshot } : undefined;
163
- });
154
+ return withLocalConfigReadLockIfNeeded(async () => {
155
+ const configPath = await prepareLocalConfigPath(validateForMigration);
156
+ const snapshot = await readConfigSnapshotIfExists(configPath);
157
+ return snapshot ? { path: configPath, ...snapshot } : undefined;
158
+ });
164
159
  }
165
160
 
166
161
  async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promise<T> {
167
- if ((await pathExists(localConfigPath())) || (await pathExists(legacyLocalConfigPath()))) {
168
- return withLocalConfigFileLock(read);
169
- }
170
- await afterMissingConfigReadProbeHook();
171
- if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
172
- return read();
162
+ if ((await pathExists(localConfigPath())) || (await pathExists(legacyLocalConfigPath()))) {
163
+ return withLocalConfigFileLock(read);
164
+ }
165
+ await afterMissingConfigReadProbeHook();
166
+ if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
167
+ return read();
173
168
  }
174
169
 
175
170
  export function updateLocalConfigDocument<T extends Record<string, unknown>>(
176
- defaultValue: T,
177
- update: (current: T) => T,
178
- validate: (value: Record<string, unknown>) => void,
179
- signal?: AbortSignal,
171
+ defaultValue: T,
172
+ update: (current: T) => T,
173
+ validate: (value: Record<string, unknown>) => void,
174
+ signal?: AbortSignal,
180
175
  ): Promise<T> {
181
- return withLocalConfigFileLock(async () => {
182
- signal?.throwIfAborted();
183
- const configPath = await prepareLocalConfigPath(validate);
184
- const snapshot = await readConfigSnapshotIfExists(configPath);
185
- const document = snapshot ? { path: configPath, ...snapshot } : undefined;
186
- const current = document
187
- ? (structuredClone(document.parsed) as T)
188
- : structuredClone(defaultValue);
189
- const next = update(current);
190
- validate(next);
191
- signal?.throwIfAborted();
192
- if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
193
- if (document) await replaceLocalConfigDocumentUnlocked(document, next);
194
- else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
195
- return next;
196
- });
176
+ return withLocalConfigFileLock(async () => {
177
+ signal?.throwIfAborted();
178
+ const configPath = await prepareLocalConfigPath(validate);
179
+ const snapshot = await readConfigSnapshotIfExists(configPath);
180
+ const document = snapshot ? { path: configPath, ...snapshot } : undefined;
181
+ const current = document ? (structuredClone(document.parsed) as T) : structuredClone(defaultValue);
182
+ const next = update(current);
183
+ validate(next);
184
+ signal?.throwIfAborted();
185
+ if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
186
+ if (document) await replaceLocalConfigDocumentUnlocked(document, next);
187
+ else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
188
+ return next;
189
+ });
197
190
  }
198
191
 
199
192
  export function createLocalConfigDocument(value: Record<string, unknown>) {
200
- return withLocalConfigFileLock(async () => {
201
- const bytes = serializedConfig(value);
202
- try {
203
- await installPrivateConfigExclusively(localConfigPath(), bytes);
204
- } catch (error) {
205
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
206
- throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
207
- }
208
- throw error;
209
- }
210
- });
211
- }
212
-
213
- export function replaceLocalConfigDocument(
214
- document: LocalConfigDocument,
215
- value: Record<string, unknown>,
216
- ) {
217
- return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
218
- }
219
-
220
- async function replaceLocalConfigDocumentUnlocked(
221
- document: LocalConfigDocument,
222
- value: Record<string, unknown>,
223
- ) {
224
- const nextBytes = serializedConfig(value);
225
- const canonicalPath = localConfigPath();
226
- if (document.path !== canonicalPath) {
227
- if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
228
- let installed: FileIdentity;
229
- try {
230
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
231
- } catch (error) {
232
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
233
- throw new Error("Canonical settings were created concurrently; no settings were replaced.");
234
- }
235
- throw error;
236
- }
237
- if (!(await configDocumentStillMatches(document))) {
238
- await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
239
- throw settingsChangedError();
240
- }
241
- return;
242
- }
243
-
244
- const quarantinePath = await claimCanonicalConfigDocument(document);
245
- let installed: FileIdentity;
246
- try {
247
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
248
- } catch (error) {
249
- await restoreQuarantinedConfig(canonicalPath, quarantinePath);
250
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
251
- throw new Error("Canonical settings changed concurrently; no settings were replaced.");
252
- }
253
- throw error;
254
- }
255
- try {
256
- await afterReplacementInstalledHook();
257
- if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
258
- throw settingsChangedError();
259
- }
260
- if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
261
- } catch (error) {
262
- await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
263
- await restoreQuarantinedConfig(canonicalPath, quarantinePath);
264
- throw error;
265
- }
266
- await fs.rm(quarantinePath).catch(() => undefined);
267
- await syncParentDirectory(canonicalPath).catch(() => undefined);
268
- }
269
-
270
- async function prepareLocalConfigPath(
271
- validateForMigration: (settings: Record<string, unknown>) => void,
272
- ) {
273
- const canonicalPath = localConfigPath();
274
- const legacyPath = legacyLocalConfigPath();
275
- if (await pathExists(canonicalPath)) {
276
- const legacyStatus = await secureIgnoredLegacyIfPresent(legacyPath);
277
- if (legacyStatus !== "missing" && !legacyPresenceNoticed.has(canonicalPath)) {
278
- legacyPresenceNoticed.add(canonicalPath);
279
- recordConfigMigrationNotice(
280
- canonicalPath,
281
- legacyStatus === "private"
282
- ? `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence. Delete ${LEGACY_CONFIG_FILE_NAME} after confirming your settings.`
283
- : `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence, but pi-sync could not verify them as a private regular file. Secure or delete the legacy path after confirming your settings.`,
284
- );
285
- }
286
- return canonicalPath;
287
- }
288
-
289
- const legacy = await readConfigSnapshotIfExists(legacyPath);
290
- if (!legacy) return canonicalPath;
291
- validateForMigration(legacy.parsed);
292
-
293
- let installedIdentity: FileIdentity;
294
- try {
295
- installedIdentity = await installPrivateConfigExclusively(canonicalPath, legacy.bytes);
296
- } catch (error) {
297
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
298
- recordConfigMigrationNotice(
299
- canonicalPath,
300
- `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} was created concurrently and takes precedence.`,
301
- );
302
- return canonicalPath;
303
- }
304
- recordConfigMigrationNotice(
305
- canonicalPath,
306
- `Could not migrate ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the legacy settings were used for this session and were not changed.`,
307
- );
308
- return legacyPath;
309
- }
310
-
311
- if (!(await configSnapshotStillMatches(legacyPath, legacy))) {
312
- const removed = await quarantineAndRemoveConfigIfMatchesUnlocked(
313
- canonicalPath,
314
- installedIdentity,
315
- legacy.bytes,
316
- );
317
- recordConfigMigrationNotice(
318
- canonicalPath,
319
- removed
320
- ? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
321
- : `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
322
- );
323
- return removed ? legacyPath : canonicalPath;
324
- }
325
-
326
- legacyPresenceNoticed.add(canonicalPath);
327
- recordConfigMigrationNotice(
328
- canonicalPath,
329
- `pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
330
- );
331
- return canonicalPath;
193
+ return withLocalConfigFileLock(async () => {
194
+ const bytes = serializedConfig(value);
195
+ try {
196
+ await installPrivateConfigExclusively(localConfigPath(), bytes);
197
+ } catch (error) {
198
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
199
+ throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
200
+ }
201
+ throw error;
202
+ }
203
+ });
204
+ }
205
+
206
+ export function replaceLocalConfigDocument(document: LocalConfigDocument, value: Record<string, unknown>) {
207
+ return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
208
+ }
209
+
210
+ async function replaceLocalConfigDocumentUnlocked(document: LocalConfigDocument, value: Record<string, unknown>) {
211
+ const nextBytes = serializedConfig(value);
212
+ const canonicalPath = localConfigPath();
213
+ if (document.path !== canonicalPath) {
214
+ if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
215
+ let installed: FileIdentity;
216
+ try {
217
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
218
+ } catch (error) {
219
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
220
+ throw new Error("Canonical settings were created concurrently; no settings were replaced.");
221
+ }
222
+ throw error;
223
+ }
224
+ if (!(await configDocumentStillMatches(document))) {
225
+ await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
226
+ throw settingsChangedError();
227
+ }
228
+ return;
229
+ }
230
+
231
+ const quarantinePath = await claimCanonicalConfigDocument(document);
232
+ let installed: FileIdentity;
233
+ try {
234
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
235
+ } catch (error) {
236
+ await restoreQuarantinedConfig(canonicalPath, quarantinePath);
237
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
238
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
239
+ }
240
+ throw error;
241
+ }
242
+ try {
243
+ await afterReplacementInstalledHook();
244
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
245
+ throw settingsChangedError();
246
+ }
247
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
248
+ } catch (error) {
249
+ await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
250
+ await restoreQuarantinedConfig(canonicalPath, quarantinePath);
251
+ throw error;
252
+ }
253
+ await fs.rm(quarantinePath).catch(() => undefined);
254
+ await syncParentDirectory(canonicalPath).catch(() => undefined);
255
+ }
256
+
257
+ async function prepareLocalConfigPath(validateForMigration: (settings: Record<string, unknown>) => void) {
258
+ const canonicalPath = localConfigPath();
259
+ const legacyPath = legacyLocalConfigPath();
260
+ if (await pathExists(canonicalPath)) {
261
+ const legacyStatus = await secureIgnoredLegacyIfPresent(legacyPath);
262
+ if (legacyStatus !== "missing" && !legacyPresenceNoticed.has(canonicalPath)) {
263
+ legacyPresenceNoticed.add(canonicalPath);
264
+ recordConfigMigrationNotice(
265
+ canonicalPath,
266
+ legacyStatus === "private"
267
+ ? `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence. Delete ${LEGACY_CONFIG_FILE_NAME} after confirming your settings.`
268
+ : `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence, but pi-sync could not verify them as a private regular file. Secure or delete the legacy path after confirming your settings.`,
269
+ );
270
+ }
271
+ return canonicalPath;
272
+ }
273
+
274
+ const legacy = await readConfigSnapshotIfExists(legacyPath);
275
+ if (!legacy) return canonicalPath;
276
+ validateForMigration(legacy.parsed);
277
+
278
+ let installedIdentity: FileIdentity;
279
+ try {
280
+ installedIdentity = await installPrivateConfigExclusively(canonicalPath, legacy.bytes);
281
+ } catch (error) {
282
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
283
+ recordConfigMigrationNotice(
284
+ canonicalPath,
285
+ `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} was created concurrently and takes precedence.`,
286
+ );
287
+ return canonicalPath;
288
+ }
289
+ recordConfigMigrationNotice(
290
+ canonicalPath,
291
+ `Could not migrate ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the legacy settings were used for this session and were not changed.`,
292
+ );
293
+ return legacyPath;
294
+ }
295
+
296
+ if (!(await configSnapshotStillMatches(legacyPath, legacy))) {
297
+ const removed = await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installedIdentity, legacy.bytes);
298
+ recordConfigMigrationNotice(
299
+ canonicalPath,
300
+ removed
301
+ ? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
302
+ : `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
303
+ );
304
+ return removed ? legacyPath : canonicalPath;
305
+ }
306
+
307
+ legacyPresenceNoticed.add(canonicalPath);
308
+ recordConfigMigrationNotice(
309
+ canonicalPath,
310
+ `pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
311
+ );
312
+ return canonicalPath;
332
313
  }
333
314
 
334
315
  async function secureIgnoredLegacyIfPresent(filePath: string) {
335
- let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
336
- try {
337
- pathStat = await fs.lstat(filePath);
338
- } catch (error) {
339
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing" as const;
340
- return "unsafe" as const;
341
- }
342
- if (pathStat.isSymbolicLink() || !pathStat.isFile()) return "unsafe" as const;
343
-
344
- let handle: fs.FileHandle | undefined;
345
- try {
346
- handle = await fs.open(filePath, "r");
347
- const openedStat = await handle.stat();
348
- if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
349
- return "unsafe" as const;
350
- }
351
- if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
352
- await handle.chmod(0o600);
353
- }
354
- return "private" as const;
355
- } catch {
356
- return "unsafe" as const;
357
- } finally {
358
- await handle?.close().catch(() => undefined);
359
- }
316
+ let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
317
+ try {
318
+ pathStat = await fs.lstat(filePath);
319
+ } catch (error) {
320
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing" as const;
321
+ return "unsafe" as const;
322
+ }
323
+ if (pathStat.isSymbolicLink() || !pathStat.isFile()) return "unsafe" as const;
324
+
325
+ let handle: fs.FileHandle | undefined;
326
+ try {
327
+ handle = await fs.open(filePath, "r");
328
+ const openedStat = await handle.stat();
329
+ if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
330
+ return "unsafe" as const;
331
+ }
332
+ if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
333
+ await handle.chmod(0o600);
334
+ }
335
+ return "private" as const;
336
+ } catch {
337
+ return "unsafe" as const;
338
+ } finally {
339
+ await handle?.close().catch(() => undefined);
340
+ }
360
341
  }
361
342
 
362
343
  async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnapshot | undefined> {
363
- let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
364
- try {
365
- pathStat = await fs.lstat(filePath);
366
- } catch (error) {
367
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
368
- throw error;
369
- }
370
- if (pathStat.isSymbolicLink()) {
371
- throw new Error(`Refusing to read symlinked pi-sync config: ${filePath}`);
372
- }
373
- if (!pathStat.isFile()) throw new Error(`pi-sync config is not a regular file: ${filePath}`);
374
-
375
- const handle = await fs.open(filePath, "r");
376
- try {
377
- const openedStat = await handle.stat();
378
- if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
379
- throw new Error(`pi-sync config changed while opening: ${filePath}`);
380
- }
381
- if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
382
- await handle.chmod(0o600);
383
- }
384
- const bytes = await handle.readFile();
385
- return {
386
- bytes,
387
- identity: { dev: openedStat.dev, ino: openedStat.ino },
388
- parsed: parseConfigObject(bytes, filePath),
389
- };
390
- } finally {
391
- await handle.close();
392
- }
344
+ let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
345
+ try {
346
+ pathStat = await fs.lstat(filePath);
347
+ } catch (error) {
348
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
349
+ throw error;
350
+ }
351
+ if (pathStat.isSymbolicLink()) {
352
+ throw new Error(`Refusing to read symlinked pi-sync config: ${filePath}`);
353
+ }
354
+ if (!pathStat.isFile()) throw new Error(`pi-sync config is not a regular file: ${filePath}`);
355
+
356
+ const handle = await fs.open(filePath, "r");
357
+ try {
358
+ const openedStat = await handle.stat();
359
+ if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
360
+ throw new Error(`pi-sync config changed while opening: ${filePath}`);
361
+ }
362
+ if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
363
+ await handle.chmod(0o600);
364
+ }
365
+ const bytes = await handle.readFile();
366
+ return {
367
+ bytes,
368
+ identity: { dev: openedStat.dev, ino: openedStat.ino },
369
+ parsed: parseConfigObject(bytes, filePath),
370
+ };
371
+ } finally {
372
+ await handle.close();
373
+ }
393
374
  }
394
375
 
395
376
  function serializedConfig(value: Record<string, unknown>) {
396
- return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
377
+ return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
397
378
  }
398
379
 
399
380
  function parseConfigObject(bytes: Buffer, filePath: string) {
400
- let parsed: unknown;
401
- try {
402
- parsed = JSON.parse(bytes.toString("utf8"));
403
- } catch {
404
- throw new SyntaxError(`Invalid JSON in pi-sync config: ${filePath}`);
405
- }
406
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
407
- throw new Error(`pi-sync config must contain a JSON object: ${filePath}`);
408
- }
409
- return parsed as Record<string, unknown>;
381
+ let parsed: unknown;
382
+ try {
383
+ parsed = JSON.parse(bytes.toString("utf8"));
384
+ } catch {
385
+ throw new SyntaxError(`Invalid JSON in pi-sync config: ${filePath}`);
386
+ }
387
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
388
+ throw new Error(`pi-sync config must contain a JSON object: ${filePath}`);
389
+ }
390
+ return parsed as Record<string, unknown>;
410
391
  }
411
392
 
412
393
  async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
413
- await fs.mkdir(path.dirname(filePath), { recursive: true });
414
- const temporaryPath = path.join(
415
- path.dirname(filePath),
416
- `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
417
- );
418
- let handle: fs.FileHandle | undefined;
419
- try {
420
- handle = await fs.open(temporaryPath, "wx", 0o600);
421
- await handle.writeFile(bytes);
422
- if (process.platform !== "win32") await handle.chmod(0o600);
423
- await handle.sync();
424
- await handle.close();
425
- handle = undefined;
426
- await publishConfigFile(temporaryPath, filePath);
427
- const installed = await fs.lstat(filePath);
428
- let publishedHandle: fs.FileHandle | undefined;
429
- let publicationError: unknown;
430
- try {
431
- if (installed.isSymbolicLink() || !installed.isFile()) {
432
- throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
433
- }
434
- publishedHandle = await fs.open(filePath, "r+");
435
- const published = await publishedHandle.stat();
436
- if (published.dev !== installed.dev || published.ino !== installed.ino) {
437
- throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
438
- }
439
- if (process.platform !== "win32") await publishedHandle.chmod(0o600);
440
- await publishedHandle.sync();
441
- await syncParentDirectory(filePath);
442
- } catch (error) {
443
- publicationError = error;
444
- } finally {
445
- await publishedHandle?.close().catch(() => undefined);
446
- }
447
- if (publicationError) {
448
- await quarantineAndRemoveConfigIfMatchesUnlocked(
449
- filePath,
450
- { dev: installed.dev, ino: installed.ino },
451
- bytes,
452
- );
453
- throw publicationError;
454
- }
455
- return { dev: installed.dev, ino: installed.ino };
456
- } finally {
457
- await handle?.close().catch(() => undefined);
458
- await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
459
- }
394
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
395
+ const temporaryPath = path.join(
396
+ path.dirname(filePath),
397
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
398
+ );
399
+ let handle: fs.FileHandle | undefined;
400
+ try {
401
+ handle = await fs.open(temporaryPath, "wx", 0o600);
402
+ await handle.writeFile(bytes);
403
+ if (process.platform !== "win32") await handle.chmod(0o600);
404
+ await handle.sync();
405
+ await handle.close();
406
+ handle = undefined;
407
+ await publishConfigFile(temporaryPath, filePath);
408
+ const installed = await fs.lstat(filePath);
409
+ let publishedHandle: fs.FileHandle | undefined;
410
+ let publicationError: unknown;
411
+ try {
412
+ if (installed.isSymbolicLink() || !installed.isFile()) {
413
+ throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
414
+ }
415
+ publishedHandle = await fs.open(filePath, "r+");
416
+ const published = await publishedHandle.stat();
417
+ if (published.dev !== installed.dev || published.ino !== installed.ino) {
418
+ throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
419
+ }
420
+ if (process.platform !== "win32") await publishedHandle.chmod(0o600);
421
+ await publishedHandle.sync();
422
+ await syncParentDirectory(filePath);
423
+ } catch (error) {
424
+ publicationError = error;
425
+ } finally {
426
+ await publishedHandle?.close().catch(() => undefined);
427
+ }
428
+ if (publicationError) {
429
+ await quarantineAndRemoveConfigIfMatchesUnlocked(filePath, { dev: installed.dev, ino: installed.ino }, bytes);
430
+ throw publicationError;
431
+ }
432
+ return { dev: installed.dev, ino: installed.ino };
433
+ } finally {
434
+ await handle?.close().catch(() => undefined);
435
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
436
+ }
460
437
  }
461
438
 
462
439
  async function configDocumentStillMatches(document: LocalConfigDocument) {
463
- return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
440
+ return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
464
441
  }
465
442
 
466
443
  async function claimCanonicalConfigDocument(document: LocalConfigDocument) {
467
- const quarantinePath = path.join(
468
- path.dirname(document.path),
469
- `.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
470
- );
471
- try {
472
- await fs.rename(document.path, quarantinePath);
473
- await syncParentDirectory(document.path);
474
- } catch (error) {
475
- await restoreQuarantinedConfig(document.path, quarantinePath);
476
- throw error;
477
- }
478
- if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
479
- await restoreQuarantinedConfig(document.path, quarantinePath);
480
- throw settingsChangedError();
481
- }
482
- if (await pathExists(document.path)) {
483
- await restoreQuarantinedConfig(document.path, quarantinePath);
484
- throw new Error("Canonical settings changed concurrently; no settings were replaced.");
485
- }
486
- return quarantinePath;
444
+ const quarantinePath = path.join(
445
+ path.dirname(document.path),
446
+ `.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
447
+ );
448
+ try {
449
+ await fs.rename(document.path, quarantinePath);
450
+ await syncParentDirectory(document.path);
451
+ } catch (error) {
452
+ await restoreQuarantinedConfig(document.path, quarantinePath);
453
+ throw error;
454
+ }
455
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
456
+ await restoreQuarantinedConfig(document.path, quarantinePath);
457
+ throw settingsChangedError();
458
+ }
459
+ if (await pathExists(document.path)) {
460
+ await restoreQuarantinedConfig(document.path, quarantinePath);
461
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
462
+ }
463
+ return quarantinePath;
487
464
  }
488
465
 
489
466
  function settingsChangedError() {
490
- return new Error("pi-sync settings changed during migration; no settings were replaced.");
467
+ return new Error("pi-sync settings changed during migration; no settings were replaced.");
491
468
  }
492
469
 
493
470
  async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
494
- return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
471
+ return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
495
472
  }
496
473
 
497
- export function quarantineAndRemoveConfigIfMatches(
498
- filePath: string,
499
- identity: FileIdentity,
500
- expectedBytes: Buffer,
501
- ) {
502
- return withLocalConfigFileLock(() =>
503
- quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes),
504
- );
474
+ export function quarantineAndRemoveConfigIfMatches(filePath: string, identity: FileIdentity, expectedBytes: Buffer) {
475
+ return withLocalConfigFileLock(() => quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes));
505
476
  }
506
477
 
507
478
  async function quarantineAndRemoveConfigIfMatchesUnlocked(
508
- filePath: string,
509
- identity: FileIdentity,
510
- expectedBytes: Buffer,
511
- ) {
512
- const quarantinePath = path.join(
513
- path.dirname(filePath),
514
- `.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
515
- );
516
- try {
517
- await fs.rename(filePath, quarantinePath);
518
- } catch {
519
- return false;
520
- }
521
- await afterConfigQuarantinedHook();
522
- try {
523
- await syncParentDirectory(filePath);
524
- } catch {
525
- await restoreQuarantinedConfig(filePath, quarantinePath);
526
- return false;
527
- }
528
-
529
- const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
530
- if (!matches) {
531
- await restoreQuarantinedConfig(filePath, quarantinePath);
532
- return false;
533
- }
534
- if (await pathExists(filePath)) {
535
- await fs.rm(quarantinePath, { force: true });
536
- await syncParentDirectory(filePath);
537
- return false;
538
- }
539
- await fs.rm(quarantinePath);
540
- await syncParentDirectory(filePath);
541
- return true;
542
- }
543
-
544
- async function fileIdentityAndContentsMatch(
545
- filePath: string,
546
- identity: FileIdentity,
547
- expectedBytes: Buffer,
479
+ filePath: string,
480
+ identity: FileIdentity,
481
+ expectedBytes: Buffer,
548
482
  ) {
549
- try {
550
- const current = await fs.lstat(filePath);
551
- if (current.isSymbolicLink()) return false;
552
- if (current.dev !== identity.dev || current.ino !== identity.ino) return false;
553
- return (await fs.readFile(filePath)).equals(expectedBytes);
554
- } catch {
555
- return false;
556
- }
483
+ const quarantinePath = path.join(
484
+ path.dirname(filePath),
485
+ `.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
486
+ );
487
+ try {
488
+ await fs.rename(filePath, quarantinePath);
489
+ } catch {
490
+ return false;
491
+ }
492
+ await afterConfigQuarantinedHook();
493
+ try {
494
+ await syncParentDirectory(filePath);
495
+ } catch {
496
+ await restoreQuarantinedConfig(filePath, quarantinePath);
497
+ return false;
498
+ }
499
+
500
+ const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
501
+ if (!matches) {
502
+ await restoreQuarantinedConfig(filePath, quarantinePath);
503
+ return false;
504
+ }
505
+ if (await pathExists(filePath)) {
506
+ await fs.rm(quarantinePath, { force: true });
507
+ await syncParentDirectory(filePath);
508
+ return false;
509
+ }
510
+ await fs.rm(quarantinePath);
511
+ await syncParentDirectory(filePath);
512
+ return true;
513
+ }
514
+
515
+ async function fileIdentityAndContentsMatch(filePath: string, identity: FileIdentity, expectedBytes: Buffer) {
516
+ try {
517
+ const current = await fs.lstat(filePath);
518
+ if (current.isSymbolicLink()) return false;
519
+ if (current.dev !== identity.dev || current.ino !== identity.ino) return false;
520
+ return (await fs.readFile(filePath)).equals(expectedBytes);
521
+ } catch {
522
+ return false;
523
+ }
557
524
  }
558
525
 
559
526
  async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
560
- try {
561
- await renameFileWithoutReplacement(quarantinePath, filePath);
562
- if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
563
- await syncParentDirectory(filePath);
564
- return;
565
- } catch (error) {
566
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
567
- }
568
- try {
569
- if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
570
- await syncParentDirectory(filePath);
571
- } catch {
572
- // Preserve the quarantine rather than risk deleting settings that changed concurrently.
573
- }
527
+ try {
528
+ await renameFileWithoutReplacement(quarantinePath, filePath);
529
+ if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
530
+ await syncParentDirectory(filePath);
531
+ return;
532
+ } catch (error) {
533
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
534
+ }
535
+ try {
536
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
537
+ await syncParentDirectory(filePath);
538
+ } catch {
539
+ // Preserve the quarantine rather than risk deleting settings that changed concurrently.
540
+ }
574
541
  }
575
542
 
576
543
  function configMutationLockPath() {
577
- return `${localConfigPath()}.mutation-lock`;
544
+ return `${localConfigPath()}.mutation-lock`;
578
545
  }
579
546
 
580
547
  export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
581
- const configPath = localConfigPath();
582
- await fs.mkdir(path.dirname(configPath), { recursive: true });
583
- let compromisedError: Error | undefined;
584
- const release = await lockfile.lock(configPath, {
585
- fs: LOCKFILE_FS_ADAPTER,
586
- lockfilePath: configMutationLockPath(),
587
- realpath: false,
588
- stale: CONFIG_LOCK_STALE_MS,
589
- update: CONFIG_LOCK_UPDATE_MS,
590
- retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
591
- onCompromised: (error) => {
592
- compromisedError = error;
593
- },
594
- });
595
- try {
596
- const result = await run();
597
- if (compromisedError) throw compromisedError;
598
- return result;
599
- } finally {
600
- await release();
601
- }
548
+ const configPath = localConfigPath();
549
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
550
+ let compromisedError: Error | undefined;
551
+ const release = await lockfile.lock(configPath, {
552
+ fs: LOCKFILE_FS_ADAPTER,
553
+ lockfilePath: configMutationLockPath(),
554
+ realpath: false,
555
+ stale: CONFIG_LOCK_STALE_MS,
556
+ update: CONFIG_LOCK_UPDATE_MS,
557
+ retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
558
+ onCompromised: (error) => {
559
+ compromisedError = error;
560
+ },
561
+ });
562
+ try {
563
+ const result = await run();
564
+ if (compromisedError) throw compromisedError;
565
+ return result;
566
+ } finally {
567
+ await release();
568
+ }
602
569
  }
603
570
 
604
571
  async function syncParentDirectory(filePath: string) {
605
- if (process.platform === "win32") return;
606
- let handle: fs.FileHandle | undefined;
607
- try {
608
- handle = await fs.open(path.dirname(filePath), "r");
609
- await handle.sync();
610
- } finally {
611
- await handle?.close().catch(() => undefined);
612
- }
572
+ if (process.platform === "win32") return;
573
+ let handle: fs.FileHandle | undefined;
574
+ try {
575
+ handle = await fs.open(path.dirname(filePath), "r");
576
+ await handle.sync();
577
+ } finally {
578
+ await handle?.close().catch(() => undefined);
579
+ }
613
580
  }
614
581
 
615
582
  async function publishFileWithoutReplacement(source: string, destination: string) {
616
- await beforeConfigPublicationHook();
617
- await renameFileWithoutReplacement(source, destination);
583
+ await beforeConfigPublicationHook();
584
+ await renameFileWithoutReplacement(source, destination);
618
585
  }
619
586
 
620
587
  async function renameFileWithoutReplacement(source: string, destination: string) {
621
- if (await pathExists(destination)) {
622
- throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
623
- }
624
- await fs.rename(source, destination);
588
+ if (await pathExists(destination)) {
589
+ throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
590
+ }
591
+ await fs.rename(source, destination);
625
592
  }
626
593
 
627
594
  async function pathExists(filePath: string) {
628
- try {
629
- await fs.lstat(filePath);
630
- return true;
631
- } catch (error) {
632
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
633
- throw error;
634
- }
595
+ try {
596
+ await fs.lstat(filePath);
597
+ return true;
598
+ } catch (error) {
599
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
600
+ throw error;
601
+ }
635
602
  }
636
603
 
637
604
  function recordConfigMigrationNotice(configPath: string, notice: string) {
638
- if (!configMigrationNotices.has(configPath)) configMigrationNotices.set(configPath, notice);
605
+ if (!configMigrationNotices.has(configPath)) configMigrationNotices.set(configPath, notice);
639
606
  }