@narumitw/pi-sync 0.49.15 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +49 -16
  2. package/dist/chunks/attention-widget-Z7G3SYYJ.ts +9 -0
  3. package/dist/chunks/attention-widget-Z7G3SYYJ.ts.map +7 -0
  4. package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
  5. package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
  6. package/dist/chunks/{chunk-ARN2672F.ts → chunk-4CHTZMEO.ts} +23 -55
  7. package/dist/chunks/chunk-4CHTZMEO.ts.map +7 -0
  8. package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
  9. package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
  10. package/dist/chunks/{chunk-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
  11. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  12. package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
  13. package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
  14. package/dist/chunks/chunk-CCSN3F47.ts +51 -0
  15. package/dist/chunks/chunk-CCSN3F47.ts.map +7 -0
  16. package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
  17. package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
  18. package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
  19. package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
  20. package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
  21. package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
  22. package/dist/chunks/{chunk-WSOBYHIZ.ts → chunk-ETTRJDNP.ts} +4 -4
  23. package/dist/chunks/chunk-ETTRJDNP.ts.map +7 -0
  24. package/dist/chunks/{chunk-44LPGQTI.ts → chunk-G4RNQQZS.ts} +9 -7
  25. package/dist/chunks/chunk-G4RNQQZS.ts.map +7 -0
  26. package/dist/chunks/{chunk-5LZALPCD.ts → chunk-HUDQATCL.ts} +25 -25
  27. package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
  28. package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-IJ6FJFA5.ts} +9 -9
  29. package/dist/chunks/chunk-IJ6FJFA5.ts.map +7 -0
  30. package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
  31. package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
  32. package/dist/chunks/chunk-KW7YQEZN.ts +1255 -0
  33. package/dist/chunks/chunk-KW7YQEZN.ts.map +7 -0
  34. package/dist/chunks/{chunk-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -8
  35. package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
  36. package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
  37. package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
  38. package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
  39. package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
  40. package/dist/chunks/{chunk-HVUSHMVL.ts → chunk-OEX4ABY6.ts} +39 -37
  41. package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
  42. package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
  43. package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
  44. package/dist/chunks/chunk-S7YBZR42.ts +146 -0
  45. package/dist/chunks/chunk-S7YBZR42.ts.map +7 -0
  46. package/dist/chunks/{chunk-GLQLN5GV.ts → chunk-SL64RLXR.ts} +8 -6
  47. package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
  48. package/dist/chunks/chunk-SZWBGPID.ts +57 -0
  49. package/dist/chunks/chunk-SZWBGPID.ts.map +7 -0
  50. package/dist/chunks/chunk-T2B5IU74.ts +25 -0
  51. package/dist/chunks/chunk-T2B5IU74.ts.map +7 -0
  52. package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
  53. package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
  54. package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
  55. package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
  56. package/dist/chunks/chunk-THMF4BVL.ts +18 -0
  57. package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
  58. package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
  59. package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
  60. package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
  61. package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
  62. package/dist/chunks/{file-selection-RKEHALNQ.ts → file-selection-FTTKAOYX.ts} +10 -7
  63. package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
  64. package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-GMVS4PD4.ts} +39 -14
  65. package/dist/chunks/git-backend-GMVS4PD4.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +22 -0
  67. package/dist/chunks/manager-ui-R3XPUFFS.ts +1680 -0
  68. package/dist/chunks/manager-ui-R3XPUFFS.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +21 -0
  70. package/dist/chunks/{s3-backend-552LXHE4.ts → s3-backend-66O4FGBH.ts} +94 -16
  71. package/dist/chunks/s3-backend-66O4FGBH.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-5YDG5YGJ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
  73. package/dist/chunks/setup-wizard-IUB6ESEU.ts +89 -0
  74. package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +7 -0
  75. package/dist/chunks/{snapshot-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
  76. package/dist/chunks/sync-inspection-CST2KL65.ts +20 -0
  77. package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-LBX3GKCM.ts} +369 -289
  78. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +7 -0
  79. package/dist/chunks/{sync-state-SRN6YRBK.ts → sync-state-Q2FTXTW6.ts} +6 -6
  80. package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
  81. package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
  82. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
  83. package/dist/index.ts +476 -341
  84. package/dist/index.ts.map +4 -4
  85. package/docs/settings.md +30 -6
  86. package/package.json +1 -1
  87. package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
  88. package/src/backends/backend-identity.ts +40 -0
  89. package/src/backends/backend-types.ts +81 -0
  90. package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
  91. package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
  92. package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
  93. package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
  94. package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
  95. package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
  96. package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
  97. package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
  98. package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
  99. package/src/commands/command-execution.ts +330 -0
  100. package/src/commands/command-handler.ts +201 -0
  101. package/src/commands/command-types.ts +18 -0
  102. package/src/{command.ts → commands/command.ts} +2 -1
  103. package/src/commands/setup-completions.ts +6 -0
  104. package/src/settings/config.ts +238 -0
  105. package/src/{settings-management.ts → settings/settings-management.ts} +39 -14
  106. package/src/settings/settings-store.ts +109 -0
  107. package/src/settings/settings-types.ts +128 -0
  108. package/src/settings/settings-validation.ts +443 -0
  109. package/src/snapshot/session-paths.ts +84 -0
  110. package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
  111. package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
  112. package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
  113. package/src/snapshot/snapshot-types.ts +38 -0
  114. package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
  115. package/src/state/json-file.ts +20 -0
  116. package/src/{lock.ts → state/lock.ts} +3 -2
  117. package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
  118. package/src/state/state-types.ts +20 -0
  119. package/src/state/sync-state-store.ts +62 -0
  120. package/src/sync/automatic-sync.ts +103 -0
  121. package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
  122. package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
  123. package/src/sync/signals.ts +10 -0
  124. package/src/sync/startup-check.ts +128 -0
  125. package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
  126. package/src/sync/sync-error-guidance.ts +53 -0
  127. package/src/sync/sync-inspection.ts +51 -0
  128. package/src/sync/sync-loaders.ts +54 -0
  129. package/src/{sync-operations.ts → sync/sync-mutations.ts} +34 -325
  130. package/src/sync/sync-operations.ts +12 -0
  131. package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
  132. package/src/sync/sync-queries.ts +235 -0
  133. package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
  134. package/src/sync-extension.ts +64 -708
  135. package/src/sync.ts +8 -8
  136. package/src/types.ts +47 -286
  137. package/src/ui/attention-widget.ts +1 -0
  138. package/src/ui/automatic-sync-summary.ts +6 -0
  139. package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
  140. package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
  141. package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
  142. package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
  143. package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
  144. package/src/ui/manager-ui.ts +386 -0
  145. package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
  146. package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
  147. package/src/{settings-ui.ts → ui/settings-ui.ts} +17 -13
  148. package/src/ui/setup/git-ui.ts +293 -0
  149. package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
  150. package/src/ui/setup/s3-ui.ts +217 -0
  151. package/src/ui/setup/setup-actions.ts +127 -0
  152. package/src/ui/setup/setup-location-ui.ts +167 -0
  153. package/src/ui/setup/setup-name-ui.ts +34 -0
  154. package/src/ui/setup/setup-prompts.ts +92 -0
  155. package/src/ui/setup/setup-review.ts +70 -0
  156. package/src/ui/setup/setup-switcher.ts +181 -0
  157. package/src/ui/setup/setup-wizard.ts +39 -0
  158. package/src/ui/setup/text-input.ts +78 -0
  159. package/src/ui/setup/webdav-ui.ts +330 -0
  160. package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
  161. package/src/ui/sync-attention.ts +187 -0
  162. package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
  163. package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
  164. package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
  165. package/src/ui/terminal-text.ts +4 -0
  166. package/dist/chunks/chunk-44LPGQTI.ts.map +0 -7
  167. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  168. package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
  169. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  170. package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
  171. package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
  172. package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
  173. package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
  174. package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
  175. package/dist/chunks/chunk-E4COSV32.ts +0 -82
  176. package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
  177. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  178. package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
  179. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  180. package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
  181. package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
  182. package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
  183. package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
  184. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  185. package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
  186. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  187. package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
  188. package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
  189. package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
  190. package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
  191. package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
  192. package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
  193. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  194. package/src/config.ts +0 -923
  195. package/src/git-ui.ts +0 -307
  196. package/src/manager-helpers.ts +0 -64
  197. package/src/manager-ui.ts +0 -997
  198. package/src/sync-attention.ts +0 -100
  199. package/src/webdav-ui.ts +0 -421
  200. /package/dist/chunks/{manager-result-dispatcher-LWPYJAD5.ts.map → manager-result-dispatcher-FKIBM3GO.ts.map} +0 -0
  201. /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-D7RBRJNL.ts.map} +0 -0
  202. /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  203. /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  204. /package/dist/chunks/{sync-state-SRN6YRBK.ts.map → sync-inspection-CST2KL65.ts.map} +0 -0
  205. /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
  206. /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
  207. /package/src/{config-file.ts → settings/config-file.ts} +0 -0
  208. /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
  209. /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
  210. /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
  211. /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
  212. /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
  213. /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
@@ -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
+ }
@@ -0,0 +1,51 @@
1
+ import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
2
+ import type { AnySyncConfig } from "../settings/settings-types.js";
3
+ import { createSnapshot } from "../snapshot/snapshot.js";
4
+ import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
5
+ import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
6
+ import { throwIfAborted } from "./signals.js";
7
+ import { inspectRemoteSelection } from "./sync-policy.js";
8
+ import { hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
9
+
10
+ /** Advisory baseline-relative observation, never an authorization to transfer files. */
11
+ export async function inspectSync(
12
+ config: AnySyncConfig,
13
+ options: SnapshotOptions,
14
+ signal?: AbortSignal,
15
+ factory: SyncBackendFactory = createSyncBackend,
16
+ ) {
17
+ throwIfAborted(signal);
18
+ const backend = await factory(config);
19
+ throwIfAborted(signal);
20
+ const local = await createSnapshot(config.snapshotIdentity, { ...options, signal });
21
+ throwIfAborted(signal);
22
+ const state = await readStateForConfig(config);
23
+ throwIfAborted(signal);
24
+ const head = await backend.readHead(signal);
25
+ throwIfAborted(signal);
26
+ return {
27
+ head,
28
+ selectionState: head
29
+ ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] })
30
+ : undefined,
31
+ localFiles: local.files.length,
32
+ localChanged: hasLocalChanges(local, state, config),
33
+ remoteChanged: remoteChangedSinceState(head, state, config, (left, right) =>
34
+ backend.sameRevision(left, right),
35
+ ),
36
+ firstSync: !state.lastAppliedSnapshot,
37
+ emptyInclude: config.include.length === 0,
38
+ stateIdentity: syncStateFingerprint(state),
39
+ destination: backend.destination,
40
+ capability: backend.capability,
41
+ };
42
+ }
43
+
44
+ export type SyncInspection = Awaited<ReturnType<typeof inspectSync>>;
45
+
46
+ export interface StartupObservation {
47
+ setupName: string;
48
+ configIdentity: string;
49
+ checkedAt: string;
50
+ inspection: SyncInspection;
51
+ }
@@ -0,0 +1,54 @@
1
+ type SetupSwitchModule = Pick<typeof import("./setup-switch.js"), "useSyncSetup">;
2
+ type SnapshotModule = Pick<typeof import("../snapshot/snapshot.js"), "createSnapshot">;
3
+ type SyncStateModule = Pick<typeof import("./sync-state.js"), "hasLocalChanges">;
4
+ type SyncOperations = typeof import("./sync-operations.js");
5
+
6
+ type SyncInspectionModule = Pick<typeof import("./sync-inspection.js"), "inspectSync">;
7
+
8
+ export interface SyncDependencies {
9
+ loadSyncInspection(): Promise<SyncInspectionModule>;
10
+ loadSetupSwitch(): Promise<SetupSwitchModule>;
11
+ loadSnapshot(): Promise<SnapshotModule>;
12
+ loadSyncState(): Promise<SyncStateModule>;
13
+ loadSyncOperations(): Promise<SyncOperations>;
14
+ }
15
+
16
+ export interface SyncLoaders {
17
+ inspection(): Promise<SyncInspectionModule>;
18
+ setupSwitch(): Promise<SetupSwitchModule>;
19
+ snapshot(): Promise<SnapshotModule>;
20
+ syncState(): Promise<SyncStateModule>;
21
+ operations(): Promise<SyncOperations>;
22
+ }
23
+
24
+ export function createSyncLoaders(dependencies: Partial<SyncDependencies>): SyncLoaders {
25
+ const loaders: SyncLoaders = {
26
+ inspection: cachedModuleLoader(
27
+ dependencies.loadSyncInspection ?? (() => import("./sync-inspection.js")),
28
+ ),
29
+ setupSwitch: cachedModuleLoader(
30
+ dependencies.loadSetupSwitch ?? (() => import("./setup-switch.js")),
31
+ ),
32
+ snapshot: cachedModuleLoader(
33
+ dependencies.loadSnapshot ?? (() => import("../snapshot/snapshot.js")),
34
+ ),
35
+ syncState: cachedModuleLoader(dependencies.loadSyncState ?? (() => import("./sync-state.js"))),
36
+ operations: cachedModuleLoader(
37
+ dependencies.loadSyncOperations ?? (() => import("./sync-operations.js")),
38
+ ),
39
+ };
40
+ return loaders;
41
+ }
42
+
43
+ function cachedModuleLoader<Module>(load: () => Promise<Module>): () => Promise<Module> {
44
+ let pending: Promise<Module> | undefined;
45
+ return () => {
46
+ if (!pending) {
47
+ pending = load().catch((error) => {
48
+ pending = undefined;
49
+ throw error;
50
+ });
51
+ }
52
+ return pending;
53
+ };
54
+ }
@@ -1,22 +1,21 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { createSyncBackend, type SyncBackendFactory } from "./backend-factory.js";
4
+ import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
5
5
  import {
6
- loadConfig,
7
- readStateForConfig,
8
- sessionDirForApply,
9
- stateDir,
10
- syncConfigReviewFingerprint,
11
- syncSessionsWarnings,
12
- writeStateForConfig,
13
- } from "./config.js";
14
- import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "./lock.js";
6
+ expectedRemoteHead,
7
+ type PublishSnapshotResult,
8
+ type RemoteHead,
9
+ type SyncBackend,
10
+ } from "../backends/sync-backend.js";
11
+ import type { CommandOptions } from "../commands/command-types.js";
12
+ import { loadConfig } from "../settings/config.js";
13
+ import type { AnySyncConfig } from "../settings/settings-types.js";
15
14
  import {
16
- formatRemoteSelectionStatus,
17
- readSnapshotForHead,
18
- requireCompatibleRemoteSelection,
19
- } from "./remote-snapshot.js";
15
+ sessionDirForApply,
16
+ sessionDirFromContext,
17
+ snapshotOptionsForContext,
18
+ } from "../snapshot/session-paths.js";
20
19
  import {
21
20
  createSnapshot,
22
21
  filterSnapshotForConfigPolicy,
@@ -26,28 +25,28 @@ import {
26
25
  sessionSnapshotPathFromAbsolute,
27
26
  snapshotIncludesSessions,
28
27
  snapshotWithoutSessions,
29
- } from "./snapshot.js";
30
- import { applySnapshot } from "./snapshot-apply.js";
31
- import { encodeSnapshot } from "./snapshot-codec.js";
32
- import {
33
- expectedRemoteHead,
34
- type PublishSnapshotResult,
35
- type RemoteHead,
36
- type SyncBackend,
37
- } from "./sync-backend.js";
38
- import { createSyncDecision } from "./sync-decision.js";
28
+ } from "../snapshot/snapshot.js";
29
+ import { applySnapshot } from "../snapshot/snapshot-apply.js";
30
+ import { encodeSnapshot } from "../snapshot/snapshot-codec.js";
31
+ import type { Snapshot, SnapshotOptions } from "../snapshot/snapshot-types.js";
32
+ import { stateDir } from "../state/state-directory.js";
33
+ import type { SyncState } from "../state/state-types.js";
34
+ import { readStateForConfig, writeStateForConfig } from "../state/sync-state-store.js";
39
35
  import {
40
36
  countPreservedRemoteFiles,
41
- errorMessage,
42
- formatDiff,
43
37
  formatPullSummary,
44
38
  formatPushSummary,
45
39
  formatRollbackSummary,
46
- formatSnapshotOnlyDiff,
47
- publicationCapabilityDescription,
48
- safeTerminalText,
49
- } from "./sync-format.js";
50
- import { inspectRemoteSelection, remoteSelectionMismatch } from "./sync-policy.js";
40
+ } from "../ui/sync-format.js";
41
+ import {
42
+ readRemoteSnapshot,
43
+ readSnapshotForHead,
44
+ requireCompatibleRemoteSelection,
45
+ } from "./remote-snapshot.js";
46
+ import { throwIfAborted } from "./signals.js";
47
+ import { createSyncDecision } from "./sync-decision.js";
48
+ import { errorMessage } from "./sync-errors.js";
49
+ import { inspectRemoteSelection } from "./sync-policy.js";
51
50
  import {
52
51
  canPullRemoteSessionsOnFirstSync,
53
52
  canPullRemoteSettingsOnFirstSync,
@@ -61,17 +60,9 @@ import {
61
60
  snapshotsMatch,
62
61
  syncPolicyChanged,
63
62
  } from "./sync-state.js";
64
- import type {
65
- AnySyncConfig,
66
- CommandOptions,
67
- Snapshot,
68
- SnapshotOptions,
69
- SyncState,
70
- } from "./types.js";
71
63
 
72
64
  const STATUS_KEY = "sync";
73
65
  const VERSION = 1;
74
- const DEFAULT_PROFILE = "default";
75
66
  const POST_LOCAL_COMMIT_TIMEOUT_MS = 30_000;
76
67
 
77
68
  export class PublicationStatePersistenceError extends Error {
@@ -109,190 +100,6 @@ interface PushInput {
109
100
  backend?: SyncBackend;
110
101
  }
111
102
 
112
- function backendFor(config: AnySyncConfig, factory: SyncBackendFactory) {
113
- return factory(config);
114
- }
115
-
116
- export async function status(
117
- ctx: ExtensionCommandContext,
118
- options: CommandOptions,
119
- factory: SyncBackendFactory = createSyncBackend,
120
- ) {
121
- const config = await loadConfig(options.setup);
122
- throwIfAborted(options.signal);
123
- ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
124
- const backend = await backendFor(config, factory);
125
- const local = await createSnapshot(
126
- config.snapshotIdentity,
127
- snapshotOptionsForContext(ctx, config),
128
- );
129
- throwIfAborted(options.signal);
130
- const state = await readStateForConfig(config);
131
- throwIfAborted(options.signal);
132
- const head = await backend.readHead(options.signal);
133
- throwIfAborted(options.signal);
134
- const selectionState = head
135
- ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] })
136
- : undefined;
137
- const localChanged = hasLocalChanges(local, state, config);
138
-
139
- const remoteText = head
140
- ? `remote: ${head.snapshotId} from ${head.machine} at ${head.createdAt}`
141
- : "remote: empty";
142
- const remoteChanged = remoteChangedSinceState(head, state, config, (left, right) =>
143
- backend.sameRevision(left, right),
144
- );
145
- const warnings = syncSessionsWarnings(config);
146
- ctx.ui.setStatus(STATUS_KEY, undefined);
147
- ctx.ui.notify(
148
- [
149
- `sync setup: ${config.setupName}`,
150
- `storage connection: ${config.connectionName}`,
151
- `storage location: ${safeTerminalText(backend.destination)}`,
152
- `publication safety: ${publicationCapabilityDescription(backend.capability)}`,
153
- `included content: ${config.include.join(", ") || "none"}`,
154
- `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
155
- remoteText,
156
- formatRemoteSelectionStatus(selectionState),
157
- `local files: ${local.files.length}`,
158
- `local changed since last sync: ${localChanged ? "yes" : "no"}`,
159
- `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
160
- ...warnings,
161
- ].join("\n"),
162
- localChanged || remoteChanged || selectionState?.kind === "different" || warnings.length > 0
163
- ? "warning"
164
- : "info",
165
- );
166
- }
167
-
168
- export async function diff(
169
- ctx: ExtensionCommandContext,
170
- options: CommandOptions,
171
- factory: SyncBackendFactory = createSyncBackend,
172
- ) {
173
- const config = await loadConfig(options.setup);
174
- throwIfAborted(options.signal);
175
- ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
176
- const backend = await backendFor(config, factory);
177
- const local = await createSnapshot(
178
- config.snapshotIdentity,
179
- snapshotOptionsForContext(ctx, config),
180
- );
181
- throwIfAborted(options.signal);
182
- const { snapshot: remote, selectionState } = await readRemoteSnapshot(
183
- backend,
184
- config,
185
- options.signal,
186
- { allowSelectionDifference: true },
187
- );
188
- throwIfAborted(options.signal);
189
- ctx.ui.setStatus(STATUS_KEY, undefined);
190
-
191
- const warnings = syncSessionsWarnings(config);
192
- const header = [
193
- `sync setup: ${config.setupName}`,
194
- `storage connection: ${config.connectionName}`,
195
- `storage location: ${safeTerminalText(backend.destination)}`,
196
- `included content: ${config.include.join(", ") || "none"}`,
197
- `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
198
- formatRemoteSelectionStatus(selectionState),
199
- ...warnings,
200
- ].join("\n");
201
- const level = warnings.length > 0 || selectionState?.kind === "different" ? "warning" : "info";
202
- if (!remote) {
203
- ctx.ui.notify(
204
- `${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
205
- level,
206
- );
207
- return;
208
- }
209
-
210
- ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
211
- }
212
-
213
- export async function doctor(
214
- ctx: ExtensionCommandContext,
215
- options: CommandOptions,
216
- factory: SyncBackendFactory = createSyncBackend,
217
- ) {
218
- const messages: string[] = [];
219
- let level: "info" | "warning" = "info";
220
- let snapshotOptions: SnapshotOptions = {};
221
- let profile = DEFAULT_PROFILE;
222
- let backend: SyncBackend | undefined;
223
- let backendSummary: string[] = [];
224
-
225
- try {
226
- const config = await loadConfig(options.setup);
227
- throwIfAborted(options.signal);
228
- backend = await backendFor(config, factory);
229
- profile = config.snapshotIdentity;
230
- snapshotOptions = snapshotOptionsForContext(ctx, config);
231
- messages.push(
232
- `config: ok (sync setup ${config.setupName})`,
233
- `included content: ${config.include.join(", ") || "none"}`,
234
- `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
235
- );
236
- backendSummary = [
237
- `storage location: ${safeTerminalText(backend.destination)}`,
238
- `publication safety: ${publicationCapabilityDescription(backend.capability)}`,
239
- ];
240
- const warnings = syncSessionsWarnings(config);
241
- if (warnings.length > 0) {
242
- level = "warning";
243
- messages.push(...warnings);
244
- }
245
- } catch (error) {
246
- throwIfAborted(options.signal);
247
- level = "warning";
248
- messages.push(`config: ${errorMessage(error)}`);
249
- }
250
-
251
- const local = await createSnapshot(profile, snapshotOptions);
252
- throwIfAborted(options.signal);
253
- const secrets = scanSnapshot(local);
254
- if (secrets.length > 0) {
255
- level = "warning";
256
- messages.push("secret scan: possible secrets found:");
257
- messages.push(...secrets.map((secret) => `- ${secret}`));
258
- } else {
259
- messages.push(`secret scan: ok (${local.files.length} files checked)`);
260
- }
261
-
262
- const lock = await inspectLock();
263
- throwIfAborted(options.signal);
264
- if (lock.status === "valid" && isStaleLock(lock.lock)) {
265
- level = "warning";
266
- messages.push(
267
- `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
268
- );
269
- } else if (lock.status === "valid") {
270
- messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
271
- } else if (lock.status === "unreadable") {
272
- level = "warning";
273
- messages.push(
274
- "lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
275
- );
276
- } else if (await isLockGuardHeld()) {
277
- throwIfAborted(options.signal);
278
- level = "warning";
279
- messages.push("lock: guard active while metadata is missing or still being initialized");
280
- } else {
281
- messages.push("lock: free");
282
- }
283
-
284
- if (backend) {
285
- messages.push(...backendSummary);
286
- const diagnostics = await backend.diagnose(options.signal);
287
- throwIfAborted(options.signal);
288
- for (const diagnostic of diagnostics) {
289
- messages.push(diagnostic.message);
290
- if (diagnostic.level !== "info") level = "warning";
291
- }
292
- }
293
- ctx.ui.notify(messages.join("\n"), level);
294
- }
295
-
296
103
  export async function push(
297
104
  ctx: ExtensionCommandContext | ExtensionContext,
298
105
  options: CommandOptions,
@@ -302,7 +109,7 @@ export async function push(
302
109
  const config = input?.config ?? (await loadConfig(options.setup));
303
110
  throwIfAborted(options.signal);
304
111
  ctx.ui.setStatus(STATUS_KEY, `pushing ${config.setupName}`);
305
- const backend = input?.backend ?? (await backendFor(config, factory));
112
+ const backend = input?.backend ?? (await factory(config));
306
113
  const state = input?.state ?? (await readStateForConfig(config));
307
114
  throwIfAborted(options.signal);
308
115
  const local =
@@ -448,7 +255,7 @@ export async function pull(
448
255
  const config = await loadConfig(options.setup);
449
256
  throwIfAborted(options.signal);
450
257
  ctx.ui.setStatus(STATUS_KEY, `pulling ${config.setupName}`);
451
- const backend = await backendFor(config, factory);
258
+ const backend = await factory(config);
452
259
  const state = await readStateForConfig(config);
453
260
  throwIfAborted(options.signal);
454
261
  const local = await createSnapshot(
@@ -553,7 +360,7 @@ export async function syncBoth(
553
360
  ) {
554
361
  const config = await loadConfig(options.setup);
555
362
  throwIfAborted(options.signal);
556
- const backend = await backendFor(config, factory);
363
+ const backend = await factory(config);
557
364
  const state = await readStateForConfig(config);
558
365
  throwIfAborted(options.signal);
559
366
  const local = await createSnapshot(
@@ -671,52 +478,6 @@ export async function syncBoth(
671
478
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
672
479
  }
673
480
 
674
- export async function history(
675
- ctx: ExtensionCommandContext,
676
- options: CommandOptions,
677
- factory: SyncBackendFactory = createSyncBackend,
678
- ) {
679
- const config = await loadConfig(options.setup);
680
- throwIfAborted(options.signal);
681
- const backend = await backendFor(config, factory);
682
- const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
683
- throwIfAborted(options.signal);
684
- if (snapshots.length === 0) {
685
- ctx.ui.notify("No remote pi-sync history found.", "info");
686
- return;
687
- }
688
-
689
- const currentSnapshot = snapshots[0]?.snapshotId;
690
- if (ctx.mode === "tui") {
691
- const labels = snapshots.map(
692
- (item, index) =>
693
- `${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
694
- );
695
- const selected = await ctx.ui.select(
696
- `History for sync setup “${safeTerminalText(config.setupName)}”\n\nChoose a snapshot to preview rollback.`,
697
- [...labels, "Back"],
698
- );
699
- if (!selected || selected === "Back") return;
700
- throwIfAborted(options.signal);
701
- const index = labels.indexOf(selected);
702
- const snapshot = snapshots[index];
703
- if (!snapshot) return;
704
- await withLock("rollback", () =>
705
- rollback(ctx, { ...options, args: [snapshot.snapshotRef], yes: false }, factory, {
706
- backendIdentity: backend.identity,
707
- setup: config.setupName,
708
- }),
709
- );
710
- return;
711
- }
712
- ctx.ui.notify(
713
- snapshots
714
- .map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`)
715
- .join("\n"),
716
- "info",
717
- );
718
- }
719
-
720
481
  export async function rollback(
721
482
  ctx: ExtensionCommandContext,
722
483
  options: CommandOptions,
@@ -728,7 +489,7 @@ export async function rollback(
728
489
 
729
490
  const config = await loadConfig(options.setup);
730
491
  throwIfAborted(options.signal);
731
- const backend = await backendFor(config, factory);
492
+ const backend = await factory(config);
732
493
  if (
733
494
  expectedSelection &&
734
495
  (backend.identity !== expectedSelection.backendIdentity ||
@@ -837,30 +598,6 @@ function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext)
837
598
  return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
838
599
  }
839
600
 
840
- function snapshotOptionsForContext(
841
- ctx: ExtensionCommandContext | ExtensionContext,
842
- config: AnySyncConfig,
843
- ): SnapshotOptions {
844
- return {
845
- include: config.include,
846
- sessionDir: sessionDirFromContext(ctx),
847
- };
848
- }
849
-
850
- function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
851
- const manager = ctx.sessionManager as typeof ctx.sessionManager & {
852
- usesDefaultSessionDir?: () => boolean;
853
- };
854
- const usesDefaultSessionDir = manager.usesDefaultSessionDir;
855
- if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
856
- return undefined;
857
- }
858
- const getSessionDir = manager.getSessionDir;
859
- return typeof getSessionDir === "function"
860
- ? (getSessionDir.call(manager) as string | undefined)
861
- : undefined;
862
- }
863
-
864
601
  async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext, signal?: AbortSignal) {
865
602
  if (signal?.aborted || !("reload" in ctx)) return;
866
603
  if (
@@ -915,27 +652,6 @@ async function snapshotForUpload(
915
652
  return mergeRemotePreservedFiles(local, snapshot, config);
916
653
  }
917
654
 
918
- async function readRemoteSnapshot(
919
- backend: SyncBackend,
920
- config: AnySyncConfig,
921
- signal?: AbortSignal,
922
- options: { allowSelectionDifference?: boolean } = {},
923
- ) {
924
- const head = await backend.readHead(signal);
925
- if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
926
- const snapshot = await readSnapshotForHead(backend, head, signal);
927
- const selectionState = inspectRemoteSelection(config.include, snapshot);
928
- if (!options.allowSelectionDifference && selectionState.kind === "different") {
929
- const configIdentity = syncConfigReviewFingerprint(config);
930
- throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
931
- }
932
- return {
933
- head,
934
- snapshot: filterSnapshotForConfigPolicy(snapshot, config),
935
- selectionState,
936
- };
937
- }
938
-
939
655
  async function confirmPush(
940
656
  ctx: ExtensionCommandContext | ExtensionContext,
941
657
  options: CommandOptions,
@@ -967,13 +683,6 @@ async function confirmPush(
967
683
  return false;
968
684
  }
969
685
 
970
- function throwIfAborted(signal?: AbortSignal) {
971
- if (!signal?.aborted) return;
972
- throw signal.reason instanceof Error
973
- ? signal.reason
974
- : new DOMException("The operation was aborted", "AbortError");
975
- }
976
-
977
686
  function sameRemoteHead(
978
687
  backend: SyncBackend,
979
688
  left: RemoteHead | undefined,
@@ -0,0 +1,12 @@
1
+ // First-use operation surface for the cached loader; implementations stay lazy.
2
+
3
+ export {
4
+ backupLocal,
5
+ PublicationStatePersistenceError,
6
+ pull,
7
+ push,
8
+ RollbackPublicationError,
9
+ rollback,
10
+ syncBoth,
11
+ } from "./sync-mutations.js";
12
+ export { diff, doctor, history, status } from "./sync-queries.js";
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
- import { isDeniedPath, toPosix } from "./paths.js";
3
- import type { Snapshot, SnapshotSelection } from "./types.js";
2
+ import { isDeniedPath, toPosix } from "../paths.js";
3
+ import type { Snapshot, SnapshotSelection } from "../snapshot/snapshot-types.js";
4
4
 
5
5
  export const BUILT_IN_SYNC_ROOTS = [
6
6
  "settings.json",