@narumitw/pi-sync 0.49.14 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +134 -241
  2. package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
  3. package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
  4. package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
  5. package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
  6. package/dist/chunks/chunk-6UHKGALE.ts +134 -0
  7. package/dist/chunks/chunk-6UHKGALE.ts.map +7 -0
  8. package/dist/chunks/{chunk-RGJ6SOYR.ts → chunk-AUSDLD6F.ts} +4 -4
  9. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  10. package/dist/chunks/{chunk-3NDD5QPM.ts → chunk-AXXS2BPA.ts} +9 -9
  11. package/dist/chunks/chunk-AXXS2BPA.ts.map +7 -0
  12. package/dist/chunks/{chunk-APZVBSM6.ts → chunk-C7XY5NF5.ts} +214 -365
  13. package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
  14. package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
  15. package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
  16. package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
  17. package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
  18. package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
  19. package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
  20. package/dist/chunks/{chunk-LJDSHNS3.ts → chunk-GWMHAQG4.ts} +4 -4
  21. package/dist/chunks/chunk-GWMHAQG4.ts.map +7 -0
  22. package/dist/chunks/{chunk-FR6CVXAL.ts → chunk-HUDQATCL.ts} +25 -25
  23. package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
  24. package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
  25. package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
  26. package/dist/chunks/chunk-JSYMGJQQ.ts +51 -0
  27. package/dist/chunks/chunk-JSYMGJQQ.ts.map +7 -0
  28. package/dist/chunks/chunk-L7TBO32M.ts +25 -0
  29. package/dist/chunks/chunk-L7TBO32M.ts.map +7 -0
  30. package/dist/chunks/{chunk-4F3W4CSC.ts → chunk-LCRI3ABW.ts} +22 -8
  31. package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
  32. package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
  33. package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
  34. package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
  35. package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
  36. package/dist/chunks/{chunk-EWX3TPLQ.ts → chunk-OEX4ABY6.ts} +39 -37
  37. package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
  38. package/dist/chunks/chunk-OUHGBFYF.ts +1255 -0
  39. package/dist/chunks/chunk-OUHGBFYF.ts.map +7 -0
  40. package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
  41. package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
  42. package/dist/chunks/{chunk-BHJUISB4.ts → chunk-SL64RLXR.ts} +8 -6
  43. package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
  44. package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
  45. package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
  46. package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
  47. package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
  48. package/dist/chunks/chunk-THMF4BVL.ts +18 -0
  49. package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
  50. package/dist/chunks/{chunk-LONPFFNP.ts → chunk-X5OM6GXT.ts} +9 -7
  51. package/dist/chunks/chunk-X5OM6GXT.ts.map +7 -0
  52. package/dist/chunks/chunk-XBX4HBLG.ts +57 -0
  53. package/dist/chunks/chunk-XBX4HBLG.ts.map +7 -0
  54. package/dist/chunks/{chunk-5WJNWFTG.ts → chunk-YKJHPTGO.ts} +23 -55
  55. package/dist/chunks/chunk-YKJHPTGO.ts.map +7 -0
  56. package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
  57. package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
  58. package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
  59. package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
  60. package/dist/chunks/{file-selection-4UABOP44.ts → file-selection-FTTKAOYX.ts} +10 -7
  61. package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
  62. package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-OQW46G2X.ts} +42 -17
  63. package/dist/chunks/git-backend-OQW46G2X.ts.map +7 -0
  64. package/dist/chunks/manager-result-dispatcher-U23ODD32.ts +22 -0
  65. package/dist/chunks/manager-ui-ABATHC36.ts +1680 -0
  66. package/dist/chunks/manager-ui-ABATHC36.ts.map +7 -0
  67. package/dist/chunks/remote-selection-ui-ZE3TY67S.ts +21 -0
  68. package/dist/chunks/{s3-backend-3QZGO2EF.ts → s3-backend-3P33LQHU.ts} +96 -18
  69. package/dist/chunks/s3-backend-3P33LQHU.ts.map +7 -0
  70. package/dist/chunks/{setup-switch-X3W3FALZ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
  71. package/dist/chunks/setup-wizard-VB3VDOV5.ts +89 -0
  72. package/dist/chunks/setup-wizard-VB3VDOV5.ts.map +7 -0
  73. package/dist/chunks/{snapshot-WBSV4MQG.ts → snapshot-HKEWCVK6.ts} +7 -6
  74. package/dist/chunks/sync-inspection-FEP4L5TR.ts +20 -0
  75. package/dist/chunks/{sync-operations-MPAFVVHA.ts → sync-operations-ONJ27QRL.ts} +381 -299
  76. package/dist/chunks/sync-operations-ONJ27QRL.ts.map +7 -0
  77. package/dist/chunks/{sync-state-36H7ULG2.ts → sync-state-Q2FTXTW6.ts} +6 -6
  78. package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
  79. package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
  80. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
  81. package/dist/index.ts +477 -342
  82. package/dist/index.ts.map +4 -4
  83. package/docs/settings.md +211 -0
  84. package/package.json +6 -8
  85. package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
  86. package/src/backends/backend-identity.ts +40 -0
  87. package/src/backends/backend-types.ts +81 -0
  88. package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
  89. package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
  90. package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
  91. package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
  92. package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
  93. package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
  94. package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
  95. package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
  96. package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
  97. package/src/commands/command-execution.ts +330 -0
  98. package/src/commands/command-handler.ts +201 -0
  99. package/src/commands/command-types.ts +18 -0
  100. package/src/{command.ts → commands/command.ts} +2 -1
  101. package/src/commands/setup-completions.ts +6 -0
  102. package/src/settings/config.ts +238 -0
  103. package/src/{settings-management.ts → settings/settings-management.ts} +40 -14
  104. package/src/settings/settings-store.ts +109 -0
  105. package/src/settings/settings-types.ts +128 -0
  106. package/src/settings/settings-validation.ts +443 -0
  107. package/src/snapshot/session-paths.ts +84 -0
  108. package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
  109. package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
  110. package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
  111. package/src/snapshot/snapshot-types.ts +38 -0
  112. package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
  113. package/src/state/json-file.ts +20 -0
  114. package/src/{lock.ts → state/lock.ts} +3 -2
  115. package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
  116. package/src/state/state-types.ts +20 -0
  117. package/src/state/sync-state-store.ts +62 -0
  118. package/src/sync/automatic-sync.ts +103 -0
  119. package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
  120. package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
  121. package/src/sync/signals.ts +10 -0
  122. package/src/sync/startup-check.ts +128 -0
  123. package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
  124. package/src/sync/sync-error-guidance.ts +53 -0
  125. package/src/sync/sync-inspection.ts +51 -0
  126. package/src/sync/sync-loaders.ts +54 -0
  127. package/src/{sync-operations.ts → sync/sync-mutations.ts} +41 -330
  128. package/src/sync/sync-operations.ts +12 -0
  129. package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
  130. package/src/sync/sync-queries.ts +235 -0
  131. package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
  132. package/src/sync-extension.ts +64 -708
  133. package/src/sync.ts +8 -8
  134. package/src/types.ts +47 -284
  135. package/src/ui/automatic-sync-summary.ts +6 -0
  136. package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
  137. package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
  138. package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
  139. package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
  140. package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
  141. package/src/ui/manager-ui.ts +386 -0
  142. package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
  143. package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
  144. package/src/{settings-ui.ts → ui/settings-ui.ts} +42 -11
  145. package/src/ui/setup/git-ui.ts +293 -0
  146. package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
  147. package/src/ui/setup/s3-ui.ts +217 -0
  148. package/src/ui/setup/setup-actions.ts +127 -0
  149. package/src/ui/setup/setup-location-ui.ts +167 -0
  150. package/src/ui/setup/setup-name-ui.ts +34 -0
  151. package/src/ui/setup/setup-prompts.ts +92 -0
  152. package/src/ui/setup/setup-review.ts +70 -0
  153. package/src/ui/setup/setup-switcher.ts +181 -0
  154. package/src/ui/setup/setup-wizard.ts +39 -0
  155. package/src/ui/setup/text-input.ts +78 -0
  156. package/src/ui/setup/webdav-ui.ts +330 -0
  157. package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
  158. package/src/{sync-attention.ts → ui/sync-attention.ts} +75 -6
  159. package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
  160. package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
  161. package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
  162. package/src/ui/terminal-text.ts +4 -0
  163. package/dist/chunks/chunk-3NDD5QPM.ts.map +0 -7
  164. package/dist/chunks/chunk-4F3W4CSC.ts.map +0 -7
  165. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  166. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  167. package/dist/chunks/chunk-5WJNWFTG.ts.map +0 -7
  168. package/dist/chunks/chunk-APZVBSM6.ts.map +0 -7
  169. package/dist/chunks/chunk-BHJUISB4.ts.map +0 -7
  170. package/dist/chunks/chunk-EWX3TPLQ.ts.map +0 -7
  171. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  172. package/dist/chunks/chunk-FR6CVXAL.ts.map +0 -7
  173. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  174. package/dist/chunks/chunk-JNOOSPEW.ts +0 -82
  175. package/dist/chunks/chunk-JNOOSPEW.ts.map +0 -7
  176. package/dist/chunks/chunk-LJDSHNS3.ts.map +0 -7
  177. package/dist/chunks/chunk-LONPFFNP.ts.map +0 -7
  178. package/dist/chunks/chunk-RGJ6SOYR.ts.map +0 -7
  179. package/dist/chunks/chunk-W64NEHXT.ts +0 -167
  180. package/dist/chunks/chunk-W64NEHXT.ts.map +0 -7
  181. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  182. package/dist/chunks/file-selection-4UABOP44.ts.map +0 -7
  183. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  184. package/dist/chunks/manager-result-dispatcher-RNRQA3PW.ts +0 -18
  185. package/dist/chunks/manager-ui-QO2P4V3Z.ts +0 -2757
  186. package/dist/chunks/manager-ui-QO2P4V3Z.ts.map +0 -7
  187. package/dist/chunks/remote-selection-ui-LHSAGKGA.ts +0 -17
  188. package/dist/chunks/s3-backend-3QZGO2EF.ts.map +0 -7
  189. package/dist/chunks/sync-operations-MPAFVVHA.ts.map +0 -7
  190. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  191. package/src/config.ts +0 -910
  192. package/src/git-ui.ts +0 -307
  193. package/src/manager-helpers.ts +0 -64
  194. package/src/manager-ui.ts +0 -997
  195. package/src/webdav-ui.ts +0 -421
  196. /package/dist/chunks/{manager-result-dispatcher-RNRQA3PW.ts.map → manager-result-dispatcher-U23ODD32.ts.map} +0 -0
  197. /package/dist/chunks/{remote-selection-ui-LHSAGKGA.ts.map → remote-selection-ui-ZE3TY67S.ts.map} +0 -0
  198. /package/dist/chunks/{setup-switch-X3W3FALZ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  199. /package/dist/chunks/{snapshot-WBSV4MQG.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  200. /package/dist/chunks/{sync-state-36H7ULG2.ts.map → sync-inspection-FEP4L5TR.ts.map} +0 -0
  201. /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
  202. /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
  203. /package/src/{config-file.ts → settings/config-file.ts} +0 -0
  204. /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
  205. /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
  206. /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
  207. /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
  208. /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
  209. /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
@@ -3,11 +3,11 @@ import type { Dirent } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { agentDir, configuredSessionDir } from "./config.js";
7
- import { isDeniedPath, posixJoin, safeJoin, toPosix } from "./paths.js";
6
+ import { isDeniedPath, posixJoin, safeJoin, toPosix } from "../paths.js";
7
+ import { agentDir, configuredSessionDir } from "./session-paths.js";
8
8
  import { sessionStorageRoot } from "./snapshot-paths.js";
9
9
 
10
- export { isDeniedPath } from "./paths.js";
10
+ export { isDeniedPath } from "../paths.js";
11
11
  export { sessionStorageRoot } from "./snapshot-paths.js";
12
12
 
13
13
  import {
@@ -23,10 +23,10 @@ import {
23
23
  selectionForSnapshot,
24
24
  snapshotSelectionInclude,
25
25
  syncIncludeSelection,
26
- } from "./sync-policy.js";
27
- import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
26
+ } from "../sync/sync-policy.js";
27
+ import type { Snapshot, SnapshotFile, SnapshotOptions } from "./snapshot-types.js";
28
28
 
29
- export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "./sync-policy.js";
29
+ export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "../sync/sync-policy.js";
30
30
 
31
31
  const VERSION = 1;
32
32
  const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_INCLUDE.filter((name) =>
@@ -96,12 +96,13 @@ export async function createSnapshot(
96
96
  profile: string,
97
97
  options: SnapshotOptions = {},
98
98
  ): Promise<Snapshot> {
99
+ options.signal?.throwIfAborted();
99
100
  const include = effectiveInclude(options);
100
101
  const syncSessions = include.includes("sessions");
101
- const files = await collectFiles(agentDir(), {
102
- include,
103
- sessionDir: options.sessionDir ?? (await configuredSessionDir()),
104
- });
102
+ const sessionDir = options.sessionDir ?? (await configuredSessionDir());
103
+ options.signal?.throwIfAborted();
104
+ const files = await collectFiles(agentDir(), { include, sessionDir, signal: options.signal });
105
+ options.signal?.throwIfAborted();
105
106
  return {
106
107
  version: VERSION,
107
108
  id: snapshotId(),
@@ -127,48 +128,61 @@ export async function collectFiles(
127
128
  root: string,
128
129
  options: SnapshotOptions = {},
129
130
  ): Promise<SnapshotFile[]> {
131
+ const signal = options.signal;
132
+ signal?.throwIfAborted();
130
133
  const results: SnapshotFile[] = [];
131
134
  const entries = await fs.readdir(root, { withFileTypes: true });
135
+ signal?.throwIfAborted();
132
136
  const selection = syncIncludeSelection(effectiveInclude(options));
133
137
  const selectedFiles = new Set<string>(selection.builtIns);
134
138
  for (const entry of entries) {
135
139
  if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name) && selectedFiles.has(entry.name)) {
136
- await collectDirectory(results, root, entry.name);
140
+ await collectDirectory(results, root, entry.name, { signal });
137
141
  }
138
142
  }
139
143
  for (const fileName of TOP_LEVEL_FILES) {
140
144
  if (!selectedFiles.has(fileName)) continue;
141
145
  const entry = selectTopLevelFileEntry(entries, fileName);
142
- if (entry) await addFile(results, root, entry.name, fileName);
146
+ if (entry) await addFile(results, root, entry.name, fileName, signal);
143
147
  }
144
148
  for (const relativePath of selection.custom) {
145
- await collectIncludedPath(results, root, relativePath);
149
+ await collectIncludedPath(results, root, relativePath, signal);
146
150
  }
147
151
  if (selection.sessions) {
148
152
  try {
149
153
  await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
150
154
  sessionsOnly: true,
151
155
  virtualPrefix: "sessions",
156
+ signal,
152
157
  });
153
158
  } catch (error) {
154
159
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
155
160
  }
156
161
  }
162
+ signal?.throwIfAborted();
157
163
  return results.sort((left, right) => left.path.localeCompare(right.path));
158
164
  }
159
165
 
160
- async function collectIncludedPath(results: SnapshotFile[], root: string, relativePath: string) {
166
+ async function collectIncludedPath(
167
+ results: SnapshotFile[],
168
+ root: string,
169
+ relativePath: string,
170
+ signal?: AbortSignal,
171
+ ) {
172
+ signal?.throwIfAborted();
161
173
  const absolutePath = safeJoin(root, relativePath);
162
174
  try {
163
175
  const stat = await fs.lstat(absolutePath);
164
- if (stat.isFile()) await addFile(results, root, relativePath);
165
- else if (stat.isDirectory()) await collectDirectory(results, root, relativePath);
176
+ signal?.throwIfAborted();
177
+ if (stat.isFile()) await addFile(results, root, relativePath, relativePath, signal);
178
+ else if (stat.isDirectory()) await collectDirectory(results, root, relativePath, { signal });
166
179
  } catch (error) {
167
180
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
168
181
  if (!relativePath.includes("/")) {
169
182
  const entries = await fs.readdir(root, { withFileTypes: true });
183
+ signal?.throwIfAborted();
170
184
  const entry = selectTopLevelFileEntry(entries, relativePath);
171
- if (entry) await addFile(results, root, entry.name, relativePath);
185
+ if (entry) await addFile(results, root, entry.name, relativePath, signal);
172
186
  }
173
187
  }
174
188
  }
@@ -177,10 +191,12 @@ async function collectDirectory(
177
191
  results: SnapshotFile[],
178
192
  root: string,
179
193
  relativeDirectory: string,
180
- options: { sessionsOnly?: boolean; virtualPrefix?: string } = {},
194
+ options: { sessionsOnly?: boolean; virtualPrefix?: string; signal?: AbortSignal } = {},
181
195
  ) {
196
+ options.signal?.throwIfAborted();
182
197
  const absoluteDirectory = path.join(root, relativeDirectory);
183
198
  for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
199
+ options.signal?.throwIfAborted();
184
200
  const relativePath = relativeDirectory ? posixJoin(relativeDirectory, entry.name) : entry.name;
185
201
  const snapshotPath = options.virtualPrefix
186
202
  ? posixJoin(options.virtualPrefix, relativePath)
@@ -189,7 +205,7 @@ async function collectDirectory(
189
205
  if (entry.isDirectory()) {
190
206
  await collectDirectory(results, root, relativePath, options);
191
207
  } else if (entry.isFile() && (!options.sessionsOnly || isSessionFilePath(snapshotPath))) {
192
- await addFile(results, root, relativePath, snapshotPath);
208
+ await addFile(results, root, relativePath, snapshotPath, options.signal);
193
209
  }
194
210
  }
195
211
  }
@@ -199,10 +215,13 @@ async function addFile(
199
215
  root: string,
200
216
  relativePath: string,
201
217
  snapshotPath = relativePath,
218
+ signal?: AbortSignal,
202
219
  ) {
220
+ signal?.throwIfAborted();
203
221
  if (!isSafeSnapshotPath(snapshotPath)) return;
204
222
  const absolutePath = safeJoin(root, relativePath);
205
- const content = await fs.readFile(absolutePath);
223
+ const content = await fs.readFile(absolutePath, { signal });
224
+ signal?.throwIfAborted();
206
225
  results.push({
207
226
  path: snapshotPath,
208
227
  contentBase64: content.toString("base64"),
@@ -0,0 +1,20 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
6
+ try {
7
+ return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
8
+ } catch (error) {
9
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
10
+ throw error;
11
+ }
12
+ }
13
+
14
+ export async function writeJson(filePath: string, value: unknown) {
15
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
16
+ const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
17
+ await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
18
+ if (process.platform !== "win32") await fs.chmod(temp, 0o600);
19
+ await fs.rename(temp, filePath);
20
+ }
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
4
  import lockfile from "proper-lockfile";
5
- import { ensureStateDir, lockPath } from "./config.js";
5
+ import type { CommandOptions } from "../commands/command-types.js";
6
6
  import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "./lock-policy.js";
7
7
  import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
8
- import type { CommandOptions, LockFile } from "./types.js";
8
+ import type { LockFile } from "./state-types.js";
9
+ import { ensureStateDir, lockPath } from "./sync-state-store.js";
9
10
 
10
11
  const LOCK_STALE_MS = 30 * 60 * 1000;
11
12
  const MAX_PROCESS_ID = 2_147_483_647;
@@ -1,6 +1,6 @@
1
+ import { errorMessage } from "../sync/sync-errors.js";
1
2
  import { inspectLock, isLockGuardHeld, isStaleLock, type LockInspection } from "./lock.js";
2
- import { errorMessage } from "./manager-helpers.js";
3
- import type { LockFile } from "./types.js";
3
+ import type { LockFile } from "./state-types.js";
4
4
 
5
5
  export type OperationAvailability =
6
6
  | { kind: "free" }
@@ -0,0 +1,20 @@
1
+ export interface SyncState {
2
+ version: number;
3
+ profile: string;
4
+ lastAppliedSnapshot?: string;
5
+ lastRemoteRevision?: string;
6
+ lastRemoteEtag?: string;
7
+ lastFileHashes: Record<string, string>;
8
+ include?: string[];
9
+ /** Legacy state fields are read only so v3 can detect and replace stale policy state. */
10
+ syncFiles?: string[];
11
+ syncSessions?: boolean;
12
+ extraFiles?: string[];
13
+ }
14
+
15
+ export interface LockFile {
16
+ id: string;
17
+ pid: number;
18
+ command: string;
19
+ startedAt: string;
20
+ }
@@ -0,0 +1,62 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { backendIdentityCoordinates } from "../backends/backend-identity.js";
5
+ import { safeName } from "../paths.js";
6
+ import type { AnySyncConfig } from "../settings/settings-types.js";
7
+ import { readJsonIfExists, writeJson } from "./json-file.js";
8
+ import { stateDir } from "./state-directory.js";
9
+ import type { SyncState } from "./state-types.js";
10
+
11
+ const STATE_VERSION = 2;
12
+
13
+ /** Bounded observation token; do not retain the full baseline in passive UI state. */
14
+ export function syncStateFingerprint(state: SyncState) {
15
+ return createHash("sha256").update(JSON.stringify(state)).digest("hex");
16
+ }
17
+
18
+ export async function readState(profile: string): Promise<SyncState> {
19
+ return (
20
+ (await readJsonIfExists<SyncState>(statePath(profile))) ?? {
21
+ version: STATE_VERSION,
22
+ profile,
23
+ lastFileHashes: {},
24
+ }
25
+ );
26
+ }
27
+
28
+ export async function writeState(profile: string, state: SyncState) {
29
+ await writeJson(statePath(profile), state);
30
+ }
31
+
32
+ export async function readStateForConfig(config: AnySyncConfig): Promise<SyncState> {
33
+ return (
34
+ (await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
35
+ version: STATE_VERSION,
36
+ profile: config.snapshotIdentity,
37
+ lastFileHashes: {},
38
+ }
39
+ );
40
+ }
41
+
42
+ export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
43
+ await writeJson(statePathForConfig(config), state);
44
+ }
45
+
46
+ export function statePathForConfig(config: AnySyncConfig) {
47
+ const identity = backendIdentityCoordinates(config);
48
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, 16);
49
+ return path.join(stateDir(), "setups", `${config.backend.type}-${hash}.state.json`);
50
+ }
51
+
52
+ function statePath(profile: string) {
53
+ return path.join(stateDir(), `${safeName(profile)}.state.json`);
54
+ }
55
+
56
+ export function lockPath() {
57
+ return path.join(stateDir(), "lock");
58
+ }
59
+
60
+ export async function ensureStateDir() {
61
+ await fs.mkdir(stateDir(), { recursive: true });
62
+ }
@@ -0,0 +1,103 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { setSyncSetupCompletions } from "../commands/command.js";
3
+ import type { CommandOptions } from "../commands/command-types.js";
4
+ import { loadConfig, loadPartialConfig } from "../settings/config.js";
5
+ import { isMissingConfigError } from "../settings/config-errors.js";
6
+ import { consumeLocalConfigMigrationNotice } from "../settings/config-file.js";
7
+ import { configuredSyncSetupNames } from "../settings/settings-store.js";
8
+ import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
9
+ import { recoverSnapshotTransactionsOnStartup } from "../snapshot/snapshot-transaction.js";
10
+ import { withLock } from "../state/lock.js";
11
+ import { stateDirectoryMigrationNotice } from "../state/state-directory.js";
12
+ import { ensureStateDir, readStateForConfig } from "../state/sync-state-store.js";
13
+ import { safeTerminalText } from "../ui/terminal-text.js";
14
+ import { throwIfAborted } from "./signals.js";
15
+ import { errorMessage } from "./sync-errors.js";
16
+ import type { SyncLoaders } from "./sync-loaders.js";
17
+
18
+ const AUTO_SYNC_OPTIONS: CommandOptions = {
19
+ yes: true,
20
+ force: false,
21
+ stale: false,
22
+ silent: true,
23
+ reload: false,
24
+ auto: true,
25
+ args: [],
26
+ };
27
+
28
+ const STATUS_KEY = "sync";
29
+
30
+ export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
31
+ throwIfAborted(signal);
32
+ const stateNotice = stateDirectoryMigrationNotice();
33
+ if (stateNotice && ctx.hasUI) ctx.ui.notify(stateNotice, "warning");
34
+ // Recovery can restore managed files: finish it before Pi accepts user edits.
35
+ // Unlike remote inspection, it must never be detached behind startup.
36
+ await recoverSnapshotTransactionsOnStartup();
37
+ throwIfAborted(signal);
38
+ try {
39
+ const names = await configuredSyncSetupNames();
40
+ throwIfAborted(signal);
41
+ setSyncSetupCompletions(names);
42
+ } catch {
43
+ if (signal.aborted) return;
44
+ setSyncSetupCompletions([]);
45
+ }
46
+ const migrationNotice = consumeLocalConfigMigrationNotice();
47
+ if (migrationNotice && ctx.hasUI) ctx.ui.notify(migrationNotice, "warning");
48
+ throwIfAborted(signal);
49
+ if (!ctx.hasUI) return;
50
+ try {
51
+ const config = await loadConfig();
52
+ throwIfAborted(signal);
53
+ return config.automatic ? config : undefined;
54
+ } catch (error) {
55
+ throwIfAborted(signal);
56
+ if (!isMissingConfigError(error)) {
57
+ ctx.ui.notify(
58
+ `pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}`,
59
+ "warning",
60
+ );
61
+ }
62
+ }
63
+ }
64
+
65
+ export async function autoPushSessions(
66
+ ctx: ExtensionContext,
67
+ signal: AbortSignal,
68
+ loaders: SyncLoaders,
69
+ ) {
70
+ try {
71
+ const partial = await loadPartialConfig();
72
+ throwIfAborted(signal);
73
+ if (!partial.automatic) return;
74
+ if (!partial.include.includes("sessions")) return;
75
+ await ensureStateDir();
76
+ throwIfAborted(signal);
77
+ const config = await loadConfig();
78
+ throwIfAborted(signal);
79
+ if (!config.include.includes("sessions")) return;
80
+ const [operations, snapshotModule, syncStateModule] = await Promise.all([
81
+ loaders.operations(),
82
+ loaders.snapshot(),
83
+ loaders.syncState(),
84
+ ]);
85
+ throwIfAborted(signal);
86
+ await withLock("auto-session-push", async () => {
87
+ throwIfAborted(signal);
88
+ const state = await readStateForConfig(config);
89
+ throwIfAborted(signal);
90
+ const local = await snapshotModule.createSnapshot(config.snapshotIdentity, {
91
+ ...snapshotOptionsForContext(ctx, config),
92
+ signal,
93
+ });
94
+ throwIfAborted(signal);
95
+ if (!syncStateModule.hasLocalChanges(local, state, config)) return;
96
+ await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
97
+ });
98
+ } catch (error) {
99
+ if (signal.aborted || isMissingConfigError(error)) return;
100
+ ctx.ui.setStatus(STATUS_KEY, undefined);
101
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
102
+ }
103
+ }
@@ -1,6 +1,9 @@
1
- import { syncConfigReviewFingerprint } from "./config.js";
2
- import type { RemoteHead, SyncBackend } from "./sync-backend.js";
3
- import { safeTerminalText } from "./sync-format.js";
1
+ import type { RemoteHead, SyncBackend } from "../backends/sync-backend.js";
2
+ import { syncConfigReviewFingerprint } from "../settings/config.js";
3
+ import type { AnySyncConfig } from "../settings/settings-types.js";
4
+ import { filterSnapshotForConfigPolicy } from "../snapshot/snapshot.js";
5
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
6
+ import { safeTerminalText } from "../ui/terminal-text.js";
4
7
  import {
5
8
  inspectRemoteSelection,
6
9
  type RemoteSelectionState,
@@ -8,7 +11,6 @@ import {
8
11
  sameSyncInclude,
9
12
  snapshotSelectionInclude,
10
13
  } from "./sync-policy.js";
11
- import type { AnySyncConfig, Snapshot } from "./types.js";
12
14
 
13
15
  export async function readSnapshotForHead(
14
16
  backend: SyncBackend,
@@ -58,3 +60,24 @@ export function formatRemoteSelectionStatus(state: RemoteSelectionState | undefi
58
60
  function safeList(values: readonly string[]) {
59
61
  return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
60
62
  }
63
+
64
+ export async function readRemoteSnapshot(
65
+ backend: SyncBackend,
66
+ config: AnySyncConfig,
67
+ signal?: AbortSignal,
68
+ options: { allowSelectionDifference?: boolean } = {},
69
+ ) {
70
+ const head = await backend.readHead(signal);
71
+ if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
72
+ const snapshot = await readSnapshotForHead(backend, head, signal);
73
+ const selectionState = inspectRemoteSelection(config.include, snapshot);
74
+ if (!options.allowSelectionDifference && selectionState.kind === "different") {
75
+ const configIdentity = syncConfigReviewFingerprint(config);
76
+ throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
77
+ }
78
+ return {
79
+ head,
80
+ snapshot: filterSnapshotForConfigPolicy(snapshot, config),
81
+ selectionState,
82
+ };
83
+ }
@@ -1,16 +1,15 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { setSyncSetupCompletions } from "./command.js";
2
+ import { setSyncSetupCompletions } from "../commands/command.js";
3
3
  import {
4
- configuredSyncSetupNames,
5
4
  loadConfig,
6
- normalizeOnSwitch,
7
5
  syncConfigReviewIdentity,
8
6
  syncSetupReviewIdentity,
9
- updateLocalConfig,
10
- } from "./config.js";
7
+ } from "../settings/config.js";
8
+ import { configuredSyncSetupNames, updateLocalConfig } from "../settings/settings-store.js";
9
+ import type { OnSwitchAction } from "../settings/settings-types.js";
10
+ import { normalizeOnSwitch } from "../settings/settings-validation.js";
11
+ import { safeTerminalText } from "../ui/terminal-text.js";
11
12
  import { SetupPullRequiresUiError } from "./sync-errors.js";
12
- import { safeTerminalText } from "./sync-format.js";
13
- import type { OnSwitchAction } from "./types.js";
14
13
 
15
14
  export { SetupPullRequiresUiError } from "./sync-errors.js";
16
15
 
@@ -0,0 +1,10 @@
1
+ export function throwIfAborted(signal?: AbortSignal) {
2
+ if (!signal?.aborted) return;
3
+ throw signal.reason instanceof Error
4
+ ? signal.reason
5
+ : new DOMException("The operation was aborted", "AbortError");
6
+ }
7
+
8
+ export function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
9
+ return secondary ? AbortSignal.any([primary, secondary]) : primary;
10
+ }
@@ -0,0 +1,128 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfigForCheck, syncCheckConfigFingerprint } from "../settings/config.js";
3
+ import { isMissingConfigError } from "../settings/config-errors.js";
4
+ import type { AnySyncConfig } from "../settings/settings-types.js";
5
+ import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
6
+ import { withLock } from "../state/lock.js";
7
+ import { withStateDirectoryAccess } from "../state/state-directory.js";
8
+ import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
9
+ import type { SyncAttentionController } from "../ui/sync-attention.js";
10
+ import { safeTerminalText } from "../ui/terminal-text.js";
11
+ import { combineSignals, throwIfAborted } from "./signals.js";
12
+ import { errorMessage } from "./sync-errors.js";
13
+ import type { SyncLoaders } from "./sync-loaders.js";
14
+
15
+ const STATUS_KEY = "sync";
16
+
17
+ /** One owned task per extension session, never keyed by the shared headless UI. */
18
+ export function createStartupCheck(
19
+ loaders: SyncLoaders,
20
+ attention: SyncAttentionController,
21
+ timeoutMs = 30_000,
22
+ ) {
23
+ let active:
24
+ | {
25
+ controller: AbortController;
26
+ promise: Promise<void>;
27
+ owner: ExtensionContext["sessionManager"];
28
+ }
29
+ | undefined;
30
+
31
+ return {
32
+ start(ctx: ExtensionContext, sessionSignal: AbortSignal, initialConfig?: AnySyncConfig) {
33
+ if (!ctx.hasUI || sessionSignal.aborted) return;
34
+ if (active) throw new Error("Previous startup check has not been drained.");
35
+ const controller = new AbortController();
36
+ const signal = combineSignals(sessionSignal, controller.signal);
37
+ const task = { controller, owner: ctx.sessionManager, promise: Promise.resolve() };
38
+ active = task;
39
+ const isCurrent = () =>
40
+ !sessionSignal.aborted && active === task && task.owner === ctx.sessionManager;
41
+ const timer = setTimeout(
42
+ () => controller.abort(new DOMException("Startup check timed out", "TimeoutError")),
43
+ timeoutMs,
44
+ );
45
+ let config: AnySyncConfig | undefined;
46
+ let checking = false;
47
+ // Handle every detached rejection here; Pi's awaited event runner cannot catch it.
48
+ task.promise = Promise.resolve()
49
+ .then(async () => {
50
+ try {
51
+ throwIfAborted(signal);
52
+ config = initialConfig ?? (await loadConfigForCheck());
53
+ throwIfAborted(signal);
54
+ if (!config.automatic) return;
55
+ const captured = config;
56
+ const identity = syncCheckConfigFingerprint(captured);
57
+ checking = true;
58
+ ctx.ui.setStatus(STATUS_KEY, "checking in background");
59
+ const { inspectSync } = await loaders.inspection();
60
+ throwIfAborted(signal);
61
+ const currentConfig = await loadConfigForCheck();
62
+ throwIfAborted(signal);
63
+ if (syncCheckConfigFingerprint(currentConfig) !== identity) return;
64
+ // Lock order: state migration guard → sync operation → backend cache.
65
+ // Read heads may write only private cache data. Never call doctor/syncBoth.
66
+ const inspection = await withStateDirectoryAccess(() => {
67
+ throwIfAborted(signal);
68
+ return withLock("startup-check", () => {
69
+ throwIfAborted(signal);
70
+ return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal);
71
+ });
72
+ });
73
+ throwIfAborted(signal);
74
+ const latest = await loadConfigForCheck();
75
+ throwIfAborted(signal);
76
+ if (syncCheckConfigFingerprint(latest) !== identity) return;
77
+ const state = await withStateDirectoryAccess(() => readStateForConfig(latest));
78
+ throwIfAborted(signal);
79
+ if (syncStateFingerprint(state) !== inspection.stateIdentity || !isCurrent()) return;
80
+ attention.observe({
81
+ setupName: captured.setupName,
82
+ configIdentity: identity,
83
+ checkedAt: new Date().toISOString(),
84
+ inspection,
85
+ });
86
+ if (ctx.mode === "rpc") attention.notifyObservation(ctx);
87
+ } catch (error) {
88
+ if (!isCurrent() || sessionSignal.aborted || isMissingConfigError(error)) return;
89
+ if (signal.aborted && controller.signal.reason?.name !== "TimeoutError") return;
90
+ // A settings edit invalidates failures as well as successful observations.
91
+ if (config) {
92
+ const latest = await loadConfigForCheck().catch(() => undefined);
93
+ if (
94
+ !isCurrent() ||
95
+ (signal.aborted && controller.signal.reason?.name !== "TimeoutError")
96
+ )
97
+ return;
98
+ if (
99
+ !latest ||
100
+ syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)
101
+ )
102
+ return;
103
+ }
104
+ ctx.ui.notify(
105
+ `pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}. Run /sync status to retry. No startup transfer was performed.`,
106
+ "warning",
107
+ );
108
+ } finally {
109
+ clearTimeout(timer);
110
+ if (isCurrent() && checking) attention.publish(ctx);
111
+ if (active === task) active = undefined;
112
+ }
113
+ })
114
+ .catch(() => {
115
+ // UI teardown can itself throw. No unhandled rejection or retained timer.
116
+ clearTimeout(timer);
117
+ if (active === task) active = undefined;
118
+ });
119
+ },
120
+ async stop() {
121
+ const task = active;
122
+ if (!task) return;
123
+ task.controller.abort(new DOMException("Startup check cancelled", "AbortError"));
124
+ // Drain the actual work, including child/ref cleanup, before foreground guards.
125
+ await task.promise;
126
+ },
127
+ };
128
+ }
@@ -1,9 +1,12 @@
1
- import { syncConfigReviewIdentity } from "./config.js";
1
+ import { syncConfigReviewIdentity } from "../settings/config.js";
2
+ import type { AnySyncConfig } from "../settings/settings-types.js";
3
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
4
+ import type { SyncState } from "../state/state-types.js";
5
+ import { formatDiff, formatSnapshotOnlyDiff } from "../ui/sync-format.js";
6
+ import { safeTerminalText } from "../ui/terminal-text.js";
2
7
  import { SyncDecisionRequiredError } from "./sync-errors.js";
3
- import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
4
8
  import { includeFromSelectionConfig } from "./sync-policy.js";
5
9
  import { syncPolicyChanged } from "./sync-state.js";
6
- import type { AnySyncConfig, Snapshot, SyncState } from "./types.js";
7
10
 
8
11
  export type SyncDecisionKind =
9
12
  | "remote-or-policy-changed"
@@ -0,0 +1,53 @@
1
+ import { errorMessage } from "./sync-errors.js";
2
+
3
+ /** Add recovery advice without changing backend outcome semantics or printing error causes. */
4
+ export function syncErrorGuidance(error: unknown): string {
5
+ const detail = errorMessage(error);
6
+ const message = detail.length > 1200 ? `${detail.slice(0, 1200)}… (details truncated)` : detail;
7
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
8
+ const status = (error as { status?: number } | undefined)?.status;
9
+ let next: string;
10
+ if (code === "ENOSPC") {
11
+ next = "Free disk space for the private pi-sync settings/state directory, then retry.";
12
+ } else if (code && ["EACCES", "EPERM", "EROFS"].includes(code)) {
13
+ next = "Check write access to the private pi-sync settings/state directory, then retry.";
14
+ } else if (code && ["EBUSY", "EIO", "EMFILE", "ENFILE"].includes(code)) {
15
+ next =
16
+ "Check local disk availability and other processes using the private settings file, then retry.";
17
+ } else if (/changed while|type changed|reopen/iu.test(message)) {
18
+ next = "Reopen the item in /sync and review its current settings before saving again.";
19
+ } else if (
20
+ status === 401 ||
21
+ status === 403 ||
22
+ /Permission denied|Authentication failed|HTTP (401|403)|failed \((401|403)\)/iu.test(message)
23
+ ) {
24
+ next =
25
+ "Check the storage connection's credentials and remote permissions in /sync → More → Storage connections.";
26
+ } else if (status === 404 || /HTTP 404|failed \(404\)/iu.test(message)) {
27
+ next =
28
+ "Verify the connection address and setup's bucket, branch, or path with your provider; this response alone does not prove which resource is missing.";
29
+ } else if (status !== undefined && status >= 500) {
30
+ next = "Check your storage provider's service status, then retry Check setup before syncing.";
31
+ } else if (
32
+ /timed out|timeout|request failed|resolve host|Could not resolve|ENOTFOUND|ECONNREFUSED/iu.test(
33
+ message,
34
+ )
35
+ ) {
36
+ next =
37
+ "Check the server address and network connection, then run /sync doctor for the affected setup.";
38
+ } else if (/strong ETag|If-Match|If-None-Match|precondition|conditional header/iu.test(message)) {
39
+ next =
40
+ "Ask your provider to check strong ETags and conditional writes, including proxy behavior, or choose compatible storage. Do not bypass these safety checks.";
41
+ } else if (/spawn git ENOENT/iu.test(message)) {
42
+ next = "Install Git 2.30 or newer and make sure Pi can find git on PATH, then retry.";
43
+ } else if (/response exceeds.*byte limit/iu.test(message)) {
44
+ next =
45
+ "Verify the storage endpoint; the server returned an unexpectedly large response. Check your provider's logs before retrying.";
46
+ } else if (/settings|config|already exists|duplicates|same normalized/iu.test(message)) {
47
+ next =
48
+ "Correct the named field or item in /sync. If the settings file is invalid, repair it before retrying; do not replace it with defaults.";
49
+ } else {
50
+ next = "Open /sync → More → Check setup and review the reported checks before retrying.";
51
+ }
52
+ return `${message}\n${next}`;
53
+ }