@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
@@ -3,28 +3,24 @@ import path from "node:path";
3
3
  import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
5
5
  import {
6
- expectedRemoteHead,
7
- type PublishSnapshotResult,
8
- type RemoteHead,
9
- type SyncBackend,
6
+ expectedRemoteHead,
7
+ type PublishSnapshotResult,
8
+ type RemoteHead,
9
+ type SyncBackend,
10
10
  } from "../backends/sync-backend.js";
11
11
  import type { CommandOptions } from "../commands/command-types.js";
12
12
  import { loadConfig } from "../settings/config.js";
13
13
  import type { AnySyncConfig } from "../settings/settings-types.js";
14
+ import { sessionDirForApply, sessionDirFromContext, snapshotOptionsForContext } from "../snapshot/session-paths.js";
14
15
  import {
15
- sessionDirForApply,
16
- sessionDirFromContext,
17
- snapshotOptionsForContext,
18
- } from "../snapshot/session-paths.js";
19
- import {
20
- createSnapshot,
21
- filterSnapshotForConfigPolicy,
22
- mergeRemotePreservedFiles,
23
- regenerateSnapshotIdentity,
24
- scanSnapshot,
25
- sessionSnapshotPathFromAbsolute,
26
- snapshotIncludesSessions,
27
- snapshotWithoutSessions,
16
+ createSnapshot,
17
+ filterSnapshotForConfigPolicy,
18
+ mergeRemotePreservedFiles,
19
+ regenerateSnapshotIdentity,
20
+ scanSnapshot,
21
+ sessionSnapshotPathFromAbsolute,
22
+ snapshotIncludesSessions,
23
+ snapshotWithoutSessions,
28
24
  } from "../snapshot/snapshot.js";
29
25
  import { applySnapshot } from "../snapshot/snapshot-apply.js";
30
26
  import { encodeSnapshot } from "../snapshot/snapshot-codec.js";
@@ -33,679 +29,571 @@ import { stateDir } from "../state/state-directory.js";
33
29
  import type { SyncState } from "../state/state-types.js";
34
30
  import { readStateForConfig, writeStateForConfig } from "../state/sync-state-store.js";
35
31
  import {
36
- countPreservedRemoteFiles,
37
- formatPullSummary,
38
- formatPushSummary,
39
- formatRollbackSummary,
32
+ countPreservedRemoteFiles,
33
+ formatPullSummary,
34
+ formatPushSummary,
35
+ formatRollbackSummary,
40
36
  } from "../ui/sync-format.js";
41
- import {
42
- readRemoteSnapshot,
43
- readSnapshotForHead,
44
- requireCompatibleRemoteSelection,
45
- } from "./remote-snapshot.js";
37
+ import { setSyncStatus } from "../ui/sync-status.js";
38
+ import { readRemoteSnapshot, readSnapshotForHead, requireCompatibleRemoteSelection } from "./remote-snapshot.js";
46
39
  import { throwIfAborted } from "./signals.js";
47
40
  import { createSyncDecision } from "./sync-decision.js";
48
41
  import { errorMessage } from "./sync-errors.js";
49
42
  import { inspectRemoteSelection } from "./sync-policy.js";
50
43
  import {
51
- canPullRemoteSessionsOnFirstSync,
52
- canPullRemoteSettingsOnFirstSync,
53
- fileHashMap,
54
- hasLocalChanges,
55
- hasRemoteChanges,
56
- remoteChangedSinceState,
57
- sameHashes,
58
- shouldRefreshSyncedState,
59
- snapshotHashesMatchState,
60
- snapshotsMatch,
61
- syncPolicyChanged,
44
+ canPullRemoteSessionsOnFirstSync,
45
+ canPullRemoteSettingsOnFirstSync,
46
+ fileHashMap,
47
+ hasLocalChanges,
48
+ hasRemoteChanges,
49
+ remoteChangedSinceState,
50
+ sameHashes,
51
+ shouldRefreshSyncedState,
52
+ snapshotHashesMatchState,
53
+ snapshotsMatch,
54
+ syncPolicyChanged,
62
55
  } from "./sync-state.js";
63
56
 
64
- const STATUS_KEY = "sync";
65
57
  const VERSION = 1;
66
58
  const POST_LOCAL_COMMIT_TIMEOUT_MS = 30_000;
67
59
 
68
60
  export class PublicationStatePersistenceError extends Error {
69
- readonly head: RemoteHead;
70
- readonly backupPath?: string;
61
+ readonly head: RemoteHead;
62
+ readonly backupPath?: string;
71
63
 
72
- constructor(head: RemoteHead, cause: unknown, backupPath?: string) {
73
- super(
74
- `Remote publication ${head.snapshotId} is active, but local sync state could not be saved${backupPath ? `; local backup: ${backupPath}` : ""}: ${errorMessage(cause)}`,
75
- { cause },
76
- );
77
- this.name = "PublicationStatePersistenceError";
78
- this.head = head;
79
- this.backupPath = backupPath;
80
- }
64
+ constructor(head: RemoteHead, cause: unknown, backupPath?: string) {
65
+ super(
66
+ `Remote publication ${head.snapshotId} is active, but local sync state could not be saved${backupPath ? `; local backup: ${backupPath}` : ""}: ${errorMessage(cause)}`,
67
+ { cause },
68
+ );
69
+ this.name = "PublicationStatePersistenceError";
70
+ this.head = head;
71
+ this.backupPath = backupPath;
72
+ }
81
73
  }
82
74
 
83
75
  export class RollbackPublicationError extends Error {
84
- readonly backupPath: string;
76
+ readonly backupPath: string;
85
77
 
86
- constructor(backupPath: string, cause: unknown) {
87
- super(
88
- `Rollback applied locally with backup ${backupPath}, but remote publication failed: ${errorMessage(cause)}`,
89
- { cause },
90
- );
91
- this.name = "RollbackPublicationError";
92
- this.backupPath = backupPath;
93
- }
78
+ constructor(backupPath: string, cause: unknown) {
79
+ super(`Rollback applied locally with backup ${backupPath}, but remote publication failed: ${errorMessage(cause)}`, {
80
+ cause,
81
+ });
82
+ this.name = "RollbackPublicationError";
83
+ this.backupPath = backupPath;
84
+ }
94
85
  }
95
86
 
96
87
  interface PushInput {
97
- config: AnySyncConfig;
98
- state: SyncState;
99
- local: Snapshot;
100
- backend?: SyncBackend;
88
+ config: AnySyncConfig;
89
+ state: SyncState;
90
+ local: Snapshot;
91
+ backend?: SyncBackend;
101
92
  }
102
93
 
103
94
  export async function push(
104
- ctx: ExtensionCommandContext | ExtensionContext,
105
- options: CommandOptions,
106
- input?: PushInput,
107
- factory: SyncBackendFactory = createSyncBackend,
95
+ ctx: ExtensionCommandContext | ExtensionContext,
96
+ options: CommandOptions,
97
+ input?: PushInput,
98
+ factory: SyncBackendFactory = createSyncBackend,
108
99
  ) {
109
- const config = input?.config ?? (await loadConfig(options.setup));
110
- throwIfAborted(options.signal);
111
- ctx.ui.setStatus(STATUS_KEY, `pushing ${config.setupName}`);
112
- const backend = input?.backend ?? (await factory(config));
113
- const state = input?.state ?? (await readStateForConfig(config));
114
- throwIfAborted(options.signal);
115
- const local =
116
- input?.local ??
117
- (await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)));
118
- throwIfAborted(options.signal);
100
+ const config = input?.config ?? (await loadConfig(options.setup));
101
+ throwIfAborted(options.signal);
102
+ setSyncStatus(ctx, `pushing ${config.setupName}`);
103
+ const backend = input?.backend ?? (await factory(config));
104
+ const state = input?.state ?? (await readStateForConfig(config));
105
+ throwIfAborted(options.signal);
106
+ const local = input?.local ?? (await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)));
107
+ throwIfAborted(options.signal);
119
108
 
120
- let head = await backend.readHead(options.signal);
121
- let remoteForUpload = await readRemoteSnapshotForUpload(
122
- backend,
123
- config,
124
- head,
125
- state,
126
- options.signal,
127
- );
128
- if (
129
- !options.force &&
130
- !remoteForUpload &&
131
- head?.selection &&
132
- inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind ===
133
- "different"
134
- ) {
135
- remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
136
- }
137
- if (remoteForUpload && !options.force) {
138
- requireCompatibleRemoteSelection(config, remoteForUpload);
139
- }
140
- if (
141
- remoteChangedSinceState(head, state, config, (left, right) =>
142
- backend.sameRevision(left, right),
143
- ) &&
144
- !options.force
145
- ) {
146
- const remoteForConflict = remoteForUpload
147
- ? filterSnapshotForConfigPolicy(remoteForUpload, config)
148
- : undefined;
149
- if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
150
- throw createSyncDecision({
151
- kind: head ? "remote-or-policy-changed" : "remote-empty",
152
- config,
153
- state,
154
- local,
155
- remote: remoteForConflict,
156
- localChanged: hasLocalChanges(local, state, config),
157
- remoteChanged: true,
158
- directMessage:
159
- "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
160
- });
161
- }
162
- }
109
+ let head = await backend.readHead(options.signal);
110
+ let remoteForUpload = await readRemoteSnapshotForUpload(backend, config, head, state, options.signal);
111
+ if (
112
+ !options.force &&
113
+ !remoteForUpload &&
114
+ head?.selection &&
115
+ inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind === "different"
116
+ ) {
117
+ remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
118
+ }
119
+ if (remoteForUpload && !options.force) {
120
+ requireCompatibleRemoteSelection(config, remoteForUpload);
121
+ }
122
+ if (
123
+ remoteChangedSinceState(head, state, config, (left, right) => backend.sameRevision(left, right)) &&
124
+ !options.force
125
+ ) {
126
+ const remoteForConflict = remoteForUpload ? filterSnapshotForConfigPolicy(remoteForUpload, config) : undefined;
127
+ if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
128
+ throw createSyncDecision({
129
+ kind: head ? "remote-or-policy-changed" : "remote-empty",
130
+ config,
131
+ state,
132
+ local,
133
+ remote: remoteForConflict,
134
+ localChanged: hasLocalChanges(local, state, config),
135
+ remoteChanged: true,
136
+ directMessage: "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
137
+ });
138
+ }
139
+ }
163
140
 
164
- let upload = await snapshotForUpload(
165
- backend,
166
- config,
167
- local,
168
- head,
169
- remoteForUpload,
170
- options.signal,
171
- );
172
- if (!config.skipSecretScan) {
173
- const secrets = scanSnapshot(local);
174
- if (secrets.length > 0) {
175
- throw new Error(
176
- `Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
177
- );
178
- }
179
- }
141
+ let upload = await snapshotForUpload(backend, config, local, head, remoteForUpload, options.signal);
142
+ if (!config.skipSecretScan) {
143
+ const secrets = scanSnapshot(local);
144
+ if (secrets.length > 0) {
145
+ throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
146
+ }
147
+ }
180
148
 
181
- if (!(await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload))) {
182
- return "cancelled" as const;
183
- }
149
+ if (!(await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload))) {
150
+ return "cancelled" as const;
151
+ }
184
152
 
185
- if (options.force) {
186
- const refreshedHead = await backend.readHead(options.signal);
187
- if (!sameRemoteHead(backend, head, refreshedHead)) {
188
- head = refreshedHead;
189
- remoteForUpload = head
190
- ? await backend.readSnapshot(head.snapshotRef, options.signal)
191
- : undefined;
192
- upload = await snapshotForUpload(
193
- backend,
194
- config,
195
- local,
196
- head,
197
- remoteForUpload,
198
- options.signal,
199
- );
200
- if (
201
- !(await confirmPush(
202
- ctx,
203
- options,
204
- config,
205
- backend,
206
- local,
207
- upload,
208
- head,
209
- remoteForUpload,
210
- "Remote changed during review. Push the refreshed plan?",
211
- ))
212
- ) {
213
- return "cancelled" as const;
214
- }
215
- }
216
- }
153
+ if (options.force) {
154
+ const refreshedHead = await backend.readHead(options.signal);
155
+ if (!sameRemoteHead(backend, head, refreshedHead)) {
156
+ head = refreshedHead;
157
+ remoteForUpload = head ? await backend.readSnapshot(head.snapshotRef, options.signal) : undefined;
158
+ upload = await snapshotForUpload(backend, config, local, head, remoteForUpload, options.signal);
159
+ if (
160
+ !(await confirmPush(
161
+ ctx,
162
+ options,
163
+ config,
164
+ backend,
165
+ local,
166
+ upload,
167
+ head,
168
+ remoteForUpload,
169
+ "Remote changed during review. Push the refreshed plan?",
170
+ ))
171
+ ) {
172
+ return "cancelled" as const;
173
+ }
174
+ }
175
+ }
217
176
 
218
- const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
219
- signal: options.signal,
220
- onCommit: options.onCommit,
221
- });
222
- try {
223
- await writeStateForConfig(config, {
224
- version: VERSION,
225
- profile: config.snapshotIdentity,
226
- lastAppliedSnapshot: result.head.snapshotId,
227
- lastRemoteRevision: result.head.revision,
228
- lastFileHashes: fileHashMap(local),
229
- include: [...config.include],
230
- });
231
- } catch (error) {
232
- throw new PublicationStatePersistenceError(result.head, error);
233
- }
234
- if (options.signal?.aborted) return;
235
- ctx.ui.setStatus(STATUS_KEY, undefined);
236
- if (!options.silent) {
237
- ctx.ui.notify(
238
- [
239
- `Pushed ${upload.files.length} files from sync setup “${config.setupName}” as ${result.head.snapshotId}.`,
240
- ...result.warnings,
241
- ]
242
- .filter(Boolean)
243
- .join("\n"),
244
- result.warnings.length > 0 ? "warning" : "info",
245
- );
246
- }
247
- return "applied" as const;
177
+ const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
178
+ signal: options.signal,
179
+ onCommit: options.onCommit,
180
+ });
181
+ try {
182
+ await writeStateForConfig(config, {
183
+ version: VERSION,
184
+ profile: config.snapshotIdentity,
185
+ lastAppliedSnapshot: result.head.snapshotId,
186
+ lastRemoteRevision: result.head.revision,
187
+ lastFileHashes: fileHashMap(local),
188
+ include: [...config.include],
189
+ });
190
+ } catch (error) {
191
+ throw new PublicationStatePersistenceError(result.head, error);
192
+ }
193
+ if (options.signal?.aborted) return;
194
+ setSyncStatus(ctx, undefined);
195
+ if (!options.silent) {
196
+ ctx.ui.notify(
197
+ [
198
+ `Pushed ${upload.files.length} files from sync setup “${config.setupName}” as ${result.head.snapshotId}.`,
199
+ ...result.warnings,
200
+ ]
201
+ .filter(Boolean)
202
+ .join("\n"),
203
+ result.warnings.length > 0 ? "warning" : "info",
204
+ );
205
+ }
206
+ return "applied" as const;
248
207
  }
249
208
 
250
209
  export async function pull(
251
- ctx: ExtensionCommandContext | ExtensionContext,
252
- options: CommandOptions,
253
- factory: SyncBackendFactory = createSyncBackend,
210
+ ctx: ExtensionCommandContext | ExtensionContext,
211
+ options: CommandOptions,
212
+ factory: SyncBackendFactory = createSyncBackend,
254
213
  ) {
255
- const config = await loadConfig(options.setup);
256
- throwIfAborted(options.signal);
257
- ctx.ui.setStatus(STATUS_KEY, `pulling ${config.setupName}`);
258
- const backend = await factory(config);
259
- const state = await readStateForConfig(config);
260
- throwIfAborted(options.signal);
261
- const local = await createSnapshot(
262
- config.snapshotIdentity,
263
- snapshotOptionsForContext(ctx, config),
264
- );
265
- throwIfAborted(options.signal);
266
- const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
267
- throwIfAborted(options.signal);
268
- const localChanged = hasLocalChanges(local, state, config);
269
- if (!remote) {
270
- throw createSyncDecision({
271
- kind: "remote-empty",
272
- config,
273
- state,
274
- local,
275
- localChanged,
276
- remoteChanged: false,
277
- directMessage: "Remote is empty. Run /sync push from a configured machine first.",
278
- });
279
- }
214
+ const config = await loadConfig(options.setup);
215
+ throwIfAborted(options.signal);
216
+ setSyncStatus(ctx, `pulling ${config.setupName}`);
217
+ const backend = await factory(config);
218
+ const state = await readStateForConfig(config);
219
+ throwIfAborted(options.signal);
220
+ const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
221
+ throwIfAborted(options.signal);
222
+ const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
223
+ throwIfAborted(options.signal);
224
+ const localChanged = hasLocalChanges(local, state, config);
225
+ if (!remote) {
226
+ throw createSyncDecision({
227
+ kind: "remote-empty",
228
+ config,
229
+ state,
230
+ local,
231
+ localChanged,
232
+ remoteChanged: false,
233
+ directMessage: "Remote is empty. Run /sync push from a configured machine first.",
234
+ });
235
+ }
280
236
 
281
- const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
282
- if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
283
- throw createSyncDecision({
284
- kind: "both-changed",
285
- config,
286
- state,
287
- local,
288
- remote,
289
- localChanged,
290
- remoteChanged,
291
- directMessage:
292
- "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
293
- });
294
- }
237
+ const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
238
+ if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
239
+ throw createSyncDecision({
240
+ kind: "both-changed",
241
+ config,
242
+ state,
243
+ local,
244
+ remote,
245
+ localChanged,
246
+ remoteChanged,
247
+ directMessage:
248
+ "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
249
+ });
250
+ }
295
251
 
296
- if (
297
- !options.yes &&
298
- !(await ctx.ui.confirm(
299
- snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
300
- formatPullSummary(
301
- config,
302
- backend.destination,
303
- local,
304
- remote,
305
- protectedSessionPaths(ctx).size,
306
- ),
307
- ))
308
- ) {
309
- ctx.ui.setStatus(STATUS_KEY, undefined);
310
- ctx.ui.notify("Pull cancelled.", "info");
311
- return "cancelled" as const;
312
- }
252
+ if (
253
+ !options.yes &&
254
+ !(await ctx.ui.confirm(
255
+ snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
256
+ formatPullSummary(config, backend.destination, local, remote, protectedSessionPaths(ctx).size),
257
+ ))
258
+ ) {
259
+ setSyncStatus(ctx, undefined);
260
+ ctx.ui.notify("Pull cancelled.", "info");
261
+ return "cancelled" as const;
262
+ }
313
263
 
314
- throwIfAborted(options.signal);
315
- const backup = await backupLocal(
316
- config.snapshotIdentity,
317
- snapshotOptionsForContext(ctx, config),
318
- options.signal,
319
- );
320
- const applySessionDir = await sessionDirForApply(ctx, remote);
321
- throwIfAborted(options.signal);
322
- options.onCommit?.();
323
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
324
- include: config.include,
325
- sessionDir: applySessionDir,
326
- });
327
- await writeStateForConfig(config, {
328
- version: VERSION,
329
- profile: config.snapshotIdentity,
330
- lastAppliedSnapshot: remote.id,
331
- lastRemoteRevision: head?.revision,
332
- lastFileHashes,
333
- include: [...config.include],
334
- });
335
- if (options.signal?.aborted) return "applied" as const;
336
- ctx.ui.setStatus(STATUS_KEY, undefined);
337
- if (!options.silent) {
338
- ctx.ui.notify(
339
- `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
340
- "info",
341
- );
342
- } else if (
343
- options.auto &&
344
- config.include.includes("sessions") &&
345
- snapshotIncludesSessions(remote)
346
- ) {
347
- ctx.ui.notify(
348
- "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
349
- "warning",
350
- );
351
- }
352
- if (options.reload) await maybeReload(ctx, options.signal);
353
- return "applied" as const;
264
+ throwIfAborted(options.signal);
265
+ const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
266
+ const applySessionDir = await sessionDirForApply(ctx, remote);
267
+ throwIfAborted(options.signal);
268
+ options.onCommit?.();
269
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
270
+ include: config.include,
271
+ sessionDir: applySessionDir,
272
+ });
273
+ await writeStateForConfig(config, {
274
+ version: VERSION,
275
+ profile: config.snapshotIdentity,
276
+ lastAppliedSnapshot: remote.id,
277
+ lastRemoteRevision: head?.revision,
278
+ lastFileHashes,
279
+ include: [...config.include],
280
+ });
281
+ if (options.signal?.aborted) return "applied" as const;
282
+ setSyncStatus(ctx, undefined);
283
+ if (!options.silent) {
284
+ ctx.ui.notify(`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info");
285
+ } else if (options.auto && config.include.includes("sessions") && snapshotIncludesSessions(remote)) {
286
+ ctx.ui.notify(
287
+ "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
288
+ "warning",
289
+ );
290
+ }
291
+ if (options.reload) await maybeReload(ctx, options.signal);
292
+ return "applied" as const;
354
293
  }
355
294
 
356
295
  export async function syncBoth(
357
- ctx: ExtensionCommandContext | ExtensionContext,
358
- options: CommandOptions,
359
- factory: SyncBackendFactory = createSyncBackend,
296
+ ctx: ExtensionCommandContext | ExtensionContext,
297
+ options: CommandOptions,
298
+ factory: SyncBackendFactory = createSyncBackend,
360
299
  ) {
361
- const config = await loadConfig(options.setup);
362
- throwIfAborted(options.signal);
363
- const backend = await factory(config);
364
- const state = await readStateForConfig(config);
365
- throwIfAborted(options.signal);
366
- const local = await createSnapshot(
367
- config.snapshotIdentity,
368
- snapshotOptionsForContext(ctx, config),
369
- );
370
- throwIfAborted(options.signal);
371
- if (config.include.length === 0) {
372
- if (!options.silent) {
373
- ctx.ui.notify(
374
- `Sync setup “${config.setupName}” includes no files. Choose included content in /sync Settings before syncing.`,
375
- "warning",
376
- );
377
- }
378
- return;
379
- }
380
- const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
381
- throwIfAborted(options.signal);
382
- const localChanged = hasLocalChanges(local, state, config);
383
- const remoteChanged = remote
384
- ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx))
385
- : false;
386
- const firstSync = !state.lastAppliedSnapshot;
300
+ const config = await loadConfig(options.setup);
301
+ throwIfAborted(options.signal);
302
+ const backend = await factory(config);
303
+ const state = await readStateForConfig(config);
304
+ throwIfAborted(options.signal);
305
+ const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
306
+ throwIfAborted(options.signal);
307
+ if (config.include.length === 0) {
308
+ if (!options.silent) {
309
+ ctx.ui.notify(
310
+ `Sync setup “${config.setupName}” includes no files. Choose included content in /sync Settings before syncing.`,
311
+ "warning",
312
+ );
313
+ }
314
+ return;
315
+ }
316
+ const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
317
+ throwIfAborted(options.signal);
318
+ const localChanged = hasLocalChanges(local, state, config);
319
+ const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false;
320
+ const firstSync = !state.lastAppliedSnapshot;
387
321
 
388
- if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
389
- if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
390
- throw createSyncDecision({
391
- kind: "first-sync-settings-diverged",
392
- config,
393
- state,
394
- local,
395
- remote,
396
- localChanged: true,
397
- remoteChanged: true,
398
- directMessage:
399
- "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
400
- });
401
- }
402
- if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
403
- if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
404
- throw createSyncDecision({
405
- kind: "first-sync-sessions-diverged",
406
- config,
407
- state,
408
- local,
409
- remote,
410
- localChanged: true,
411
- remoteChanged: true,
412
- directMessage:
413
- "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
414
- });
415
- }
416
- await pull(ctx, options, factory);
417
- return;
418
- }
419
- await writeStateForConfig(config, {
420
- version: VERSION,
421
- profile: config.snapshotIdentity,
422
- lastAppliedSnapshot: remote.id,
423
- lastRemoteRevision: head?.revision,
424
- lastFileHashes: fileHashMap(remote),
425
- include: [...config.include],
426
- });
427
- if (!options.silent)
428
- ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
429
- return;
430
- }
431
- if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
432
- await writeStateForConfig(config, {
433
- version: VERSION,
434
- profile: config.snapshotIdentity,
435
- lastAppliedSnapshot: remote.id,
436
- lastRemoteRevision: head?.revision,
437
- lastFileHashes: fileHashMap(remote),
438
- include: [...config.include],
439
- });
440
- if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
441
- return;
442
- }
443
- if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
444
- throw createSyncDecision({
445
- kind: "both-changed",
446
- config,
447
- state,
448
- local,
449
- remote,
450
- localChanged,
451
- remoteChanged,
452
- directMessage:
453
- "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
454
- });
455
- }
456
- if (remoteChanged) {
457
- await pull(ctx, options, factory);
458
- return;
459
- }
460
- if (localChanged || !remote) {
461
- await push(ctx, options, undefined, factory);
462
- return;
463
- }
464
- if (
465
- shouldRefreshSyncedState(remote, head, state, config, (left, right) =>
466
- backend.sameRevision(left, right),
467
- )
468
- ) {
469
- await writeStateForConfig(config, {
470
- version: VERSION,
471
- profile: config.snapshotIdentity,
472
- lastAppliedSnapshot: remote.id,
473
- lastRemoteRevision: head?.revision,
474
- lastFileHashes: fileHashMap(remote),
475
- include: [...config.include],
476
- });
477
- }
478
- if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
322
+ if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
323
+ if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
324
+ throw createSyncDecision({
325
+ kind: "first-sync-settings-diverged",
326
+ config,
327
+ state,
328
+ local,
329
+ remote,
330
+ localChanged: true,
331
+ remoteChanged: true,
332
+ directMessage:
333
+ "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
334
+ });
335
+ }
336
+ if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
337
+ if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
338
+ throw createSyncDecision({
339
+ kind: "first-sync-sessions-diverged",
340
+ config,
341
+ state,
342
+ local,
343
+ remote,
344
+ localChanged: true,
345
+ remoteChanged: true,
346
+ directMessage:
347
+ "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
348
+ });
349
+ }
350
+ await pull(ctx, options, factory);
351
+ return;
352
+ }
353
+ await writeStateForConfig(config, {
354
+ version: VERSION,
355
+ profile: config.snapshotIdentity,
356
+ lastAppliedSnapshot: remote.id,
357
+ lastRemoteRevision: head?.revision,
358
+ lastFileHashes: fileHashMap(remote),
359
+ include: [...config.include],
360
+ });
361
+ if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
362
+ return;
363
+ }
364
+ if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
365
+ await writeStateForConfig(config, {
366
+ version: VERSION,
367
+ profile: config.snapshotIdentity,
368
+ lastAppliedSnapshot: remote.id,
369
+ lastRemoteRevision: head?.revision,
370
+ lastFileHashes: fileHashMap(remote),
371
+ include: [...config.include],
372
+ });
373
+ if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
374
+ return;
375
+ }
376
+ if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
377
+ throw createSyncDecision({
378
+ kind: "both-changed",
379
+ config,
380
+ state,
381
+ local,
382
+ remote,
383
+ localChanged,
384
+ remoteChanged,
385
+ directMessage: "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
386
+ });
387
+ }
388
+ if (remoteChanged) {
389
+ await pull(ctx, options, factory);
390
+ return;
391
+ }
392
+ if (localChanged || !remote) {
393
+ await push(ctx, options, undefined, factory);
394
+ return;
395
+ }
396
+ if (shouldRefreshSyncedState(remote, head, state, config, (left, right) => backend.sameRevision(left, right))) {
397
+ await writeStateForConfig(config, {
398
+ version: VERSION,
399
+ profile: config.snapshotIdentity,
400
+ lastAppliedSnapshot: remote.id,
401
+ lastRemoteRevision: head?.revision,
402
+ lastFileHashes: fileHashMap(remote),
403
+ include: [...config.include],
404
+ });
405
+ }
406
+ if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
479
407
  }
480
408
 
481
409
  export async function rollback(
482
- ctx: ExtensionCommandContext,
483
- options: CommandOptions,
484
- factory: SyncBackendFactory = createSyncBackend,
485
- expectedSelection?: { backendIdentity: string; setup?: string },
410
+ ctx: ExtensionCommandContext,
411
+ options: CommandOptions,
412
+ factory: SyncBackendFactory = createSyncBackend,
413
+ expectedSelection?: { backendIdentity: string; setup?: string },
486
414
  ) {
487
- const target = options.args[0];
488
- if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
415
+ const target = options.args[0];
416
+ if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
489
417
 
490
- const config = await loadConfig(options.setup);
491
- throwIfAborted(options.signal);
492
- const backend = await factory(config);
493
- if (
494
- expectedSelection &&
495
- (backend.identity !== expectedSelection.backendIdentity ||
496
- config.setupName !== expectedSelection.setup)
497
- ) {
498
- throw new Error(
499
- "Sync setup or storage location changed while history was open; reopen history and retry.",
500
- );
501
- }
502
- const decoded = await backend.readSnapshot(target, options.signal);
503
- const selected = filterSnapshotForConfigPolicy(
504
- config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded),
505
- config,
506
- );
507
- const remote = regenerateSnapshotIdentity(selected);
508
- const local = await createSnapshot(
509
- config.snapshotIdentity,
510
- snapshotOptionsForContext(ctx, config),
511
- );
512
- const expectedHead = await backend.readHead(options.signal);
513
- throwIfAborted(options.signal);
418
+ const config = await loadConfig(options.setup);
419
+ throwIfAborted(options.signal);
420
+ const backend = await factory(config);
421
+ if (
422
+ expectedSelection &&
423
+ (backend.identity !== expectedSelection.backendIdentity || config.setupName !== expectedSelection.setup)
424
+ ) {
425
+ throw new Error("Sync setup or storage location changed while history was open; reopen history and retry.");
426
+ }
427
+ const decoded = await backend.readSnapshot(target, options.signal);
428
+ const selected = filterSnapshotForConfigPolicy(
429
+ config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded),
430
+ config,
431
+ );
432
+ const remote = regenerateSnapshotIdentity(selected);
433
+ const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
434
+ const expectedHead = await backend.readHead(options.signal);
435
+ throwIfAborted(options.signal);
514
436
 
515
- if (
516
- !options.yes &&
517
- !(await ctx.ui.confirm(
518
- snapshotIncludesSessions(remote)
519
- ? "Rollback pi settings and sessions?"
520
- : "Rollback pi settings?",
521
- formatRollbackSummary(
522
- config,
523
- backend.destination,
524
- local,
525
- remote,
526
- target,
527
- protectedSessionPaths(ctx).size,
528
- ),
529
- ))
530
- ) {
531
- ctx.ui.notify("Rollback cancelled.", "info");
532
- return;
533
- }
437
+ if (
438
+ !options.yes &&
439
+ !(await ctx.ui.confirm(
440
+ snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
441
+ formatRollbackSummary(config, backend.destination, local, remote, target, protectedSessionPaths(ctx).size),
442
+ ))
443
+ ) {
444
+ ctx.ui.notify("Rollback cancelled.", "info");
445
+ return;
446
+ }
534
447
 
535
- throwIfAborted(options.signal);
536
- const backup = await backupLocal(
537
- config.snapshotIdentity,
538
- snapshotOptionsForContext(ctx, config),
539
- options.signal,
540
- );
541
- const applySessionDir = await sessionDirForApply(ctx, remote);
542
- throwIfAborted(options.signal);
543
- options.onCommit?.();
544
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
545
- include: config.include,
546
- sessionDir: applySessionDir,
547
- });
548
- let result: PublishSnapshotResult;
549
- try {
550
- const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS);
551
- const upload = await snapshotForUpload(
552
- backend,
553
- config,
554
- remote,
555
- expectedHead,
556
- undefined,
557
- completionSignal,
558
- { ignoreUnreadableRemote: true },
559
- );
560
- result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), {
561
- signal: completionSignal,
562
- });
563
- } catch (error) {
564
- throw new RollbackPublicationError(backup, error);
565
- }
566
- try {
567
- await writeStateForConfig(config, {
568
- version: VERSION,
569
- profile: config.snapshotIdentity,
570
- lastAppliedSnapshot: result.head.snapshotId,
571
- lastRemoteRevision: result.head.revision,
572
- lastFileHashes,
573
- include: [...config.include],
574
- });
575
- } catch (error) {
576
- throw new PublicationStatePersistenceError(result.head, error, backup);
577
- }
578
- if (options.signal?.aborted) return;
579
- ctx.ui.notify(
580
- [
581
- `Rolled back sync setup “${config.setupName}” to ${target}; latest: ${result.head.snapshotId}. Backup: ${backup}`,
582
- ...result.warnings,
583
- ]
584
- .filter(Boolean)
585
- .join("\n"),
586
- result.warnings.length > 0 ? "warning" : "info",
587
- );
588
- await maybeReload(ctx, options.signal);
448
+ throwIfAborted(options.signal);
449
+ const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
450
+ const applySessionDir = await sessionDirForApply(ctx, remote);
451
+ throwIfAborted(options.signal);
452
+ options.onCommit?.();
453
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
454
+ include: config.include,
455
+ sessionDir: applySessionDir,
456
+ });
457
+ let result: PublishSnapshotResult;
458
+ try {
459
+ const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS);
460
+ const upload = await snapshotForUpload(backend, config, remote, expectedHead, undefined, completionSignal, {
461
+ ignoreUnreadableRemote: true,
462
+ });
463
+ result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), {
464
+ signal: completionSignal,
465
+ });
466
+ } catch (error) {
467
+ throw new RollbackPublicationError(backup, error);
468
+ }
469
+ try {
470
+ await writeStateForConfig(config, {
471
+ version: VERSION,
472
+ profile: config.snapshotIdentity,
473
+ lastAppliedSnapshot: result.head.snapshotId,
474
+ lastRemoteRevision: result.head.revision,
475
+ lastFileHashes,
476
+ include: [...config.include],
477
+ });
478
+ } catch (error) {
479
+ throw new PublicationStatePersistenceError(result.head, error, backup);
480
+ }
481
+ if (options.signal?.aborted) return;
482
+ ctx.ui.notify(
483
+ [
484
+ `Rolled back sync setup “${config.setupName}” to ${target}; latest: ${result.head.snapshotId}. Backup: ${backup}`,
485
+ ...result.warnings,
486
+ ]
487
+ .filter(Boolean)
488
+ .join("\n"),
489
+ result.warnings.length > 0 ? "warning" : "info",
490
+ );
491
+ await maybeReload(ctx, options.signal);
589
492
  }
590
493
 
591
494
  function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
592
- const getSessionFile = ctx.sessionManager.getSessionFile;
593
- if (typeof getSessionFile !== "function") return new Set<string>();
594
- const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
595
- const snapshotPath = sessionFile
596
- ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
597
- : undefined;
598
- return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
495
+ const getSessionFile = ctx.sessionManager.getSessionFile;
496
+ if (typeof getSessionFile !== "function") return new Set<string>();
497
+ const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
498
+ const snapshotPath = sessionFile
499
+ ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
500
+ : undefined;
501
+ return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
599
502
  }
600
503
 
601
504
  async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext, signal?: AbortSignal) {
602
- if (signal?.aborted || !("reload" in ctx)) return;
603
- if (
604
- ctx.hasUI &&
605
- (await ctx.ui.confirm(
606
- "Reload Pi resources now?",
607
- "This reloads extensions, skills, prompts, themes, and context files.",
608
- ))
609
- ) {
610
- if (signal?.aborted) return;
611
- await ctx.reload();
612
- }
505
+ if (signal?.aborted || !("reload" in ctx)) return;
506
+ if (
507
+ ctx.hasUI &&
508
+ (await ctx.ui.confirm(
509
+ "Reload Pi resources now?",
510
+ "This reloads extensions, skills, prompts, themes, and context files.",
511
+ ))
512
+ ) {
513
+ if (signal?.aborted) return;
514
+ await ctx.reload();
515
+ }
613
516
  }
614
517
 
615
518
  async function readRemoteSnapshotForUpload(
616
- backend: SyncBackend,
617
- config: AnySyncConfig,
618
- head: RemoteHead | undefined,
619
- state: SyncState,
620
- signal?: AbortSignal,
519
+ backend: SyncBackend,
520
+ config: AnySyncConfig,
521
+ head: RemoteHead | undefined,
522
+ state: SyncState,
523
+ signal?: AbortSignal,
621
524
  ) {
622
- if (
623
- !head ||
624
- (head.snapshotId === state.lastAppliedSnapshot &&
625
- !syncPolicyChanged(state, config) &&
626
- (!state.lastRemoteRevision || backend.sameRevision(head.revision, state.lastRemoteRevision)))
627
- ) {
628
- return undefined;
629
- }
630
- return backend.readSnapshot(head.snapshotRef, signal);
525
+ if (
526
+ !head ||
527
+ (head.snapshotId === state.lastAppliedSnapshot &&
528
+ !syncPolicyChanged(state, config) &&
529
+ (!state.lastRemoteRevision || backend.sameRevision(head.revision, state.lastRemoteRevision)))
530
+ ) {
531
+ return undefined;
532
+ }
533
+ return backend.readSnapshot(head.snapshotRef, signal);
631
534
  }
632
535
 
633
536
  async function snapshotForUpload(
634
- backend: SyncBackend,
635
- config: AnySyncConfig,
636
- local: Snapshot,
637
- head: RemoteHead | undefined,
638
- remote?: Snapshot,
639
- signal?: AbortSignal,
640
- options: { ignoreUnreadableRemote?: boolean } = {},
537
+ backend: SyncBackend,
538
+ config: AnySyncConfig,
539
+ local: Snapshot,
540
+ head: RemoteHead | undefined,
541
+ remote?: Snapshot,
542
+ signal?: AbortSignal,
543
+ options: { ignoreUnreadableRemote?: boolean } = {},
641
544
  ) {
642
- if (!head) return local;
643
- let snapshot = remote;
644
- if (!snapshot) {
645
- try {
646
- snapshot = await backend.readSnapshot(head.snapshotRef, signal);
647
- } catch (error) {
648
- if (options.ignoreUnreadableRemote) return local;
649
- throw error;
650
- }
651
- }
652
- return mergeRemotePreservedFiles(local, snapshot, config);
545
+ if (!head) return local;
546
+ let snapshot = remote;
547
+ if (!snapshot) {
548
+ try {
549
+ snapshot = await backend.readSnapshot(head.snapshotRef, signal);
550
+ } catch (error) {
551
+ if (options.ignoreUnreadableRemote) return local;
552
+ throw error;
553
+ }
554
+ }
555
+ return mergeRemotePreservedFiles(local, snapshot, config);
653
556
  }
654
557
 
655
558
  async function confirmPush(
656
- ctx: ExtensionCommandContext | ExtensionContext,
657
- options: CommandOptions,
658
- config: AnySyncConfig,
659
- backend: SyncBackend,
660
- local: Snapshot,
661
- upload: Snapshot,
662
- head: RemoteHead | undefined,
663
- remote: Snapshot | undefined,
664
- title = snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
559
+ ctx: ExtensionCommandContext | ExtensionContext,
560
+ options: CommandOptions,
561
+ config: AnySyncConfig,
562
+ backend: SyncBackend,
563
+ local: Snapshot,
564
+ upload: Snapshot,
565
+ head: RemoteHead | undefined,
566
+ remote: Snapshot | undefined,
567
+ title = snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
665
568
  ) {
666
- throwIfAborted(options.signal);
667
- if (options.yes) return true;
668
- const confirmed = await ctx.ui.confirm(
669
- title,
670
- formatPushSummary(
671
- config,
672
- backend.destination,
673
- upload,
674
- head,
675
- countPreservedRemoteFiles(local, upload),
676
- remote,
677
- ),
678
- );
679
- throwIfAborted(options.signal);
680
- if (confirmed) return true;
681
- ctx.ui.setStatus(STATUS_KEY, undefined);
682
- ctx.ui.notify("Push cancelled.", "info");
683
- return false;
569
+ throwIfAborted(options.signal);
570
+ if (options.yes) return true;
571
+ const confirmed = await ctx.ui.confirm(
572
+ title,
573
+ formatPushSummary(config, backend.destination, upload, head, countPreservedRemoteFiles(local, upload), remote),
574
+ );
575
+ throwIfAborted(options.signal);
576
+ if (confirmed) return true;
577
+ setSyncStatus(ctx, undefined);
578
+ ctx.ui.notify("Push cancelled.", "info");
579
+ return false;
684
580
  }
685
581
 
686
- function sameRemoteHead(
687
- backend: SyncBackend,
688
- left: RemoteHead | undefined,
689
- right: RemoteHead | undefined,
690
- ) {
691
- if (!left || !right) return left === right;
692
- return backend.sameRevision(left.revision, right.revision);
582
+ function sameRemoteHead(backend: SyncBackend, left: RemoteHead | undefined, right: RemoteHead | undefined) {
583
+ if (!left || !right) return left === right;
584
+ return backend.sameRevision(left.revision, right.revision);
693
585
  }
694
586
 
695
- export async function backupLocal(
696
- profile: string,
697
- options: SnapshotOptions = {},
698
- signal?: AbortSignal,
699
- ) {
700
- throwIfAborted(signal);
701
- const snapshot = await createSnapshot(profile, options);
702
- throwIfAborted(signal);
703
- const backupDirectory = path.join(stateDir(), "backups");
704
- await fs.mkdir(backupDirectory, { recursive: true });
705
- throwIfAborted(signal);
706
- const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
707
- const encoded = await encodeSnapshot(snapshot);
708
- throwIfAborted(signal);
709
- await fs.writeFile(backupPath, encoded, { signal });
710
- return backupPath;
587
+ export async function backupLocal(profile: string, options: SnapshotOptions = {}, signal?: AbortSignal) {
588
+ throwIfAborted(signal);
589
+ const snapshot = await createSnapshot(profile, options);
590
+ throwIfAborted(signal);
591
+ const backupDirectory = path.join(stateDir(), "backups");
592
+ await fs.mkdir(backupDirectory, { recursive: true });
593
+ throwIfAborted(signal);
594
+ const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
595
+ const encoded = await encodeSnapshot(snapshot);
596
+ throwIfAborted(signal);
597
+ await fs.writeFile(backupPath, encoded, { signal });
598
+ return backupPath;
711
599
  }