@narumitw/pi-sync 0.50.3 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
  3. package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
  4. package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
  5. package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
  6. package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
  7. package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
  8. package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
  9. package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
  10. package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
  11. package/dist/chunks/chunk-6546TS54.ts.map +7 -0
  12. package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
  13. package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
  14. package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
  15. package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
  16. package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
  17. package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
  18. package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
  19. package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
  20. package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
  21. package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
  22. package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
  23. package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
  24. package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
  25. package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
  26. package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
  27. package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
  28. package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
  29. package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
  30. package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
  31. package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
  32. package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
  33. package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
  34. package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
  35. package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
  36. package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
  37. package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
  38. package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
  39. package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
  40. package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
  41. package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
  42. package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
  43. package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
  44. package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
  45. package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
  46. package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
  47. package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
  48. package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
  49. package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
  50. package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
  51. package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
  52. package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
  53. package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
  54. package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
  55. package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
  56. package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
  57. package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
  58. package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
  59. package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
  60. package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
  61. package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
  62. package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
  63. package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
  64. package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
  65. package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
  67. package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
  68. package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
  70. package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
  71. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
  73. package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
  74. package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
  75. package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
  76. package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
  77. package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
  78. package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
  79. package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
  80. package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
  81. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
  82. package/dist/index.ts +49 -74
  83. package/dist/index.ts.map +3 -3
  84. package/docs/settings.md +9 -0
  85. package/package.json +61 -62
  86. package/src/backends/backend-factory.ts +15 -15
  87. package/src/backends/backend-identity.ts +31 -31
  88. package/src/backends/backend-types.ts +42 -42
  89. package/src/backends/git/git-backend.ts +714 -788
  90. package/src/backends/git/git-config.ts +104 -111
  91. package/src/backends/git/git-runner.ts +230 -247
  92. package/src/backends/git/git-storage.ts +228 -245
  93. package/src/backends/s3/s3-backend.ts +408 -441
  94. package/src/backends/s3/s3-client.ts +288 -333
  95. package/src/backends/sync-backend.ts +65 -65
  96. package/src/backends/webdav/webdav-backend.ts +542 -589
  97. package/src/backends/webdav/webdav-client.ts +292 -316
  98. package/src/backends/webdav/webdav-config.ts +57 -68
  99. package/src/commands/command-execution.ts +270 -289
  100. package/src/commands/command-handler.ts +160 -163
  101. package/src/commands/command-types.ts +13 -13
  102. package/src/commands/command.ts +190 -196
  103. package/src/commands/setup-completions.ts +1 -1
  104. package/src/paths.ts +47 -47
  105. package/src/settings/config-errors.ts +4 -5
  106. package/src/settings/config-file.ts +473 -506
  107. package/src/settings/config.ts +171 -174
  108. package/src/settings/settings-management.ts +191 -216
  109. package/src/settings/settings-store.ts +63 -77
  110. package/src/settings/settings-types.ts +78 -75
  111. package/src/settings/settings-validation.ts +328 -364
  112. package/src/snapshot/session-paths.ts +41 -55
  113. package/src/snapshot/snapshot-apply.ts +197 -229
  114. package/src/snapshot/snapshot-codec.ts +29 -31
  115. package/src/snapshot/snapshot-paths.ts +4 -6
  116. package/src/snapshot/snapshot-transaction.ts +161 -170
  117. package/src/snapshot/snapshot-types.ts +24 -24
  118. package/src/snapshot/snapshot.ts +310 -343
  119. package/src/state/json-file.ts +11 -11
  120. package/src/state/lock.ts +226 -242
  121. package/src/state/lockfile-fs.ts +20 -20
  122. package/src/state/operation-availability.ts +36 -41
  123. package/src/state/state-directory.ts +171 -173
  124. package/src/state/state-types.ts +15 -15
  125. package/src/state/sync-state-store.ts +23 -23
  126. package/src/sync/automatic-sync.ts +75 -80
  127. package/src/sync/remote-snapshot.ts +52 -60
  128. package/src/sync/setup-switch.ts +99 -114
  129. package/src/sync/signals.ts +3 -5
  130. package/src/sync/startup-check.ts +97 -111
  131. package/src/sync/sync-decision.ts +76 -80
  132. package/src/sync/sync-error-guidance.ts +41 -48
  133. package/src/sync/sync-errors.ts +10 -10
  134. package/src/sync/sync-inspection.ts +29 -33
  135. package/src/sync/sync-loaders.ts +28 -36
  136. package/src/sync/sync-mutations.ts +510 -622
  137. package/src/sync/sync-operations.ts +7 -7
  138. package/src/sync/sync-policy.ts +296 -326
  139. package/src/sync/sync-queries.ts +175 -194
  140. package/src/sync/sync-state.ts +74 -100
  141. package/src/sync-extension.ts +89 -101
  142. package/src/sync.ts +26 -26
  143. package/src/types.ts +37 -37
  144. package/src/ui/automatic-sync-summary.ts +1 -3
  145. package/src/ui/cancellable-operation.ts +89 -101
  146. package/src/ui/file-selection.ts +250 -274
  147. package/src/ui/manager-attention.ts +51 -63
  148. package/src/ui/manager-recovery.ts +48 -57
  149. package/src/ui/manager-result-dispatcher.ts +62 -76
  150. package/src/ui/manager-state.ts +200 -232
  151. package/src/ui/manager-ui.ts +264 -362
  152. package/src/ui/remote-selection-ui.ts +547 -588
  153. package/src/ui/secret-input.ts +197 -205
  154. package/src/ui/settings-ui.ts +175 -172
  155. package/src/ui/setup/git-ui.ts +232 -261
  156. package/src/ui/setup/s3-credentials-ui.ts +58 -65
  157. package/src/ui/setup/s3-ui.ts +171 -195
  158. package/src/ui/setup/setup-actions.ts +91 -101
  159. package/src/ui/setup/setup-location-ui.ts +121 -137
  160. package/src/ui/setup/setup-name-ui.ts +27 -27
  161. package/src/ui/setup/setup-prompts.ts +65 -65
  162. package/src/ui/setup/setup-review.ts +58 -59
  163. package/src/ui/setup/setup-switcher.ts +156 -160
  164. package/src/ui/setup/setup-wizard.ts +28 -28
  165. package/src/ui/setup/text-input.ts +54 -58
  166. package/src/ui/setup/webdav-ui.ts +246 -293
  167. package/src/ui/storage-connections-ui.ts +317 -369
  168. package/src/ui/sync-attention.ts +148 -164
  169. package/src/ui/sync-format.ts +110 -121
  170. package/src/ui/sync-resolution-ui.ts +147 -153
  171. package/src/ui/sync-setups-ui.ts +218 -238
  172. package/src/ui/sync-status.ts +17 -0
  173. package/src/ui/terminal-text.ts +2 -2
  174. package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
  175. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  176. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  177. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  178. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  179. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  180. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  181. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  182. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  183. package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  186. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  187. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  188. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  189. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  190. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  191. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  192. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  193. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  194. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  195. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  196. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  197. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  198. package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
  199. package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
  200. package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
  201. package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
  202. package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
  203. package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
  204. package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
  205. package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
  206. package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
  210. package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
  211. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
  212. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
  213. /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
  215. /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
  216. /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
  217. /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
  218. /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
@@ -1,84 +1,70 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import {
4
- type ExtensionCommandContext,
5
- type ExtensionContext,
6
- getAgentDir,
7
- } from "@earendil-works/pi-coding-agent";
3
+ import { type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
8
4
  import type { AnySyncConfig } from "../settings/settings-types.js";
9
5
  import { readJsonIfExists } from "../state/json-file.js";
10
6
  import type { Snapshot, SnapshotOptions } from "./snapshot-types.js";
11
7
 
12
8
  export function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
13
- const manager = ctx.sessionManager as typeof ctx.sessionManager & {
14
- usesDefaultSessionDir?: () => boolean;
15
- };
16
- const usesDefaultSessionDir = manager.usesDefaultSessionDir;
17
- if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
18
- return undefined;
19
- }
20
- const getSessionDir = manager.getSessionDir;
21
- return typeof getSessionDir === "function"
22
- ? (getSessionDir.call(manager) as string | undefined)
23
- : undefined;
9
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
10
+ usesDefaultSessionDir?: () => boolean;
11
+ };
12
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
13
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
14
+ return undefined;
15
+ }
16
+ const getSessionDir = manager.getSessionDir;
17
+ return typeof getSessionDir === "function" ? (getSessionDir.call(manager) as string | undefined) : undefined;
24
18
  }
25
19
 
26
20
  export async function configuredSessionDir() {
27
- const settings = await readJsonIfExists<{ sessionDir?: string }>(
28
- path.join(agentDir(), "settings.json"),
29
- );
30
- return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
21
+ const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
22
+ return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
31
23
  }
32
24
 
33
- export async function sessionDirForApply(
34
- ctx: ExtensionCommandContext | ExtensionContext,
35
- snapshot: Snapshot,
36
- ) {
37
- const contextSessionDir = sessionDirFromContext(ctx);
38
- const localSessionDir = await configuredSessionDir();
39
- if (
40
- contextSessionDir &&
41
- path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")
42
- ) {
43
- return contextSessionDir;
44
- }
45
- return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
25
+ export async function sessionDirForApply(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
26
+ const contextSessionDir = sessionDirFromContext(ctx);
27
+ const localSessionDir = await configuredSessionDir();
28
+ if (contextSessionDir && path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")) {
29
+ return contextSessionDir;
30
+ }
31
+ return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
46
32
  }
47
33
 
48
34
  function sessionDirFromSnapshot(snapshot: Snapshot) {
49
- const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
50
- if (!settingsFile) return undefined;
51
- try {
52
- const settings = JSON.parse(
53
- decodeBase64Strict(settingsFile.contentBase64, settingsFile.path).toString("utf8"),
54
- ) as { sessionDir?: string };
55
- return settings.sessionDir ? expandHome(settings.sessionDir) : undefined;
56
- } catch {
57
- return undefined;
58
- }
35
+ const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
36
+ if (!settingsFile) return undefined;
37
+ try {
38
+ const settings = JSON.parse(decodeBase64Strict(settingsFile.contentBase64, settingsFile.path).toString("utf8")) as {
39
+ sessionDir?: string;
40
+ };
41
+ return settings.sessionDir ? expandHome(settings.sessionDir) : undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
59
45
  }
60
46
 
61
47
  function decodeBase64Strict(value: string, filePath: string) {
62
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
63
- throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
64
- }
65
- return Buffer.from(value, "base64");
48
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
49
+ throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
50
+ }
51
+ return Buffer.from(value, "base64");
66
52
  }
67
53
 
68
54
  export function agentDir() {
69
- return getAgentDir();
55
+ return getAgentDir();
70
56
  }
71
57
 
72
58
  function expandHome(value: string) {
73
- return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
59
+ return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
74
60
  }
75
61
 
76
62
  export function snapshotOptionsForContext(
77
- ctx: ExtensionCommandContext | ExtensionContext,
78
- config: AnySyncConfig,
63
+ ctx: ExtensionCommandContext | ExtensionContext,
64
+ config: AnySyncConfig,
79
65
  ): SnapshotOptions {
80
- return {
81
- include: config.include,
82
- sessionDir: sessionDirFromContext(ctx),
83
- };
66
+ return {
67
+ include: config.include,
68
+ sessionDir: sessionDirFromContext(ctx),
69
+ };
84
70
  }
@@ -2,288 +2,256 @@ import { createHash } from "node:crypto";
2
2
  import type { Dirent } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import {
6
- assertWithinRoot,
7
- isDeniedPath,
8
- isPathInside,
9
- parentPaths,
10
- safeJoin,
11
- toPosix,
12
- } from "../paths.js";
5
+ import { assertWithinRoot, isDeniedPath, isPathInside, parentPaths, safeJoin, toPosix } from "../paths.js";
13
6
  import { agentDir } from "./session-paths.js";
14
7
  import {
15
- createSnapshot,
16
- isSessionFilePath,
17
- isSessionPath,
18
- sessionStorageRoot,
19
- snapshotIncludesSessions,
20
- snapshotTarget,
8
+ createSnapshot,
9
+ isSessionFilePath,
10
+ isSessionPath,
11
+ sessionStorageRoot,
12
+ snapshotIncludesSessions,
13
+ snapshotTarget,
21
14
  } from "./snapshot.js";
22
- import {
23
- applySnapshotTransaction,
24
- recoverPendingSnapshotTransactions,
25
- } from "./snapshot-transaction.js";
15
+ import { applySnapshotTransaction, recoverPendingSnapshotTransactions } from "./snapshot-transaction.js";
26
16
  import type { Snapshot, SnapshotApplyPlan, SnapshotOptions } from "./snapshot-types.js";
27
17
 
28
18
  function sha256(value: Buffer) {
29
- return createHash("sha256").update(value).digest("hex");
19
+ return createHash("sha256").update(value).digest("hex");
30
20
  }
31
21
 
32
22
  function fileHashMap(snapshot: Snapshot) {
33
- return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
23
+ return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
34
24
  }
35
25
 
36
26
  export async function applySnapshot(
37
- snapshot: Snapshot,
38
- protectedRelativePaths = new Set<string>(),
39
- options: Pick<
40
- SnapshotOptions,
41
- "include" | "sessionDir" | "syncFiles" | "syncSessions" | "extraFiles"
42
- > = {},
27
+ snapshot: Snapshot,
28
+ protectedRelativePaths = new Set<string>(),
29
+ options: Pick<SnapshotOptions, "include" | "sessionDir" | "syncFiles" | "syncSessions" | "extraFiles"> = {},
43
30
  ) {
44
- const root = agentDir();
45
- const { sessionDir } = options;
46
- await recoverPendingSnapshotTransactions();
47
- const current = await createSnapshot(snapshot.profile, {
48
- ...options,
49
- ...(options.include === undefined ? { syncSessions: snapshotIncludesSessions(snapshot) } : {}),
50
- sessionDir,
51
- });
52
- const plan = await addTopLevelCaseVariantDeletes(
53
- root,
54
- protectSnapshotApplyPlan(
55
- root,
56
- preflightSnapshotApply(root, snapshot, current, { sessionDir }),
57
- protectedRelativePaths,
58
- sessionDir,
59
- ),
60
- snapshot,
61
- );
62
- await preflightSnapshotMutations(root, plan, sessionDir);
63
- await applySnapshotTransaction(plan, { sessionDir });
64
- return appliedFileHashMap(snapshot, current, protectedRelativePaths);
31
+ const root = agentDir();
32
+ const { sessionDir } = options;
33
+ await recoverPendingSnapshotTransactions();
34
+ const current = await createSnapshot(snapshot.profile, {
35
+ ...options,
36
+ ...(options.include === undefined ? { syncSessions: snapshotIncludesSessions(snapshot) } : {}),
37
+ sessionDir,
38
+ });
39
+ const plan = await addTopLevelCaseVariantDeletes(
40
+ root,
41
+ protectSnapshotApplyPlan(
42
+ root,
43
+ preflightSnapshotApply(root, snapshot, current, { sessionDir }),
44
+ protectedRelativePaths,
45
+ sessionDir,
46
+ ),
47
+ snapshot,
48
+ );
49
+ await preflightSnapshotMutations(root, plan, sessionDir);
50
+ await applySnapshotTransaction(plan, { sessionDir });
51
+ return appliedFileHashMap(snapshot, current, protectedRelativePaths);
65
52
  }
66
53
 
67
54
  export function preflightSnapshotApply(
68
- root: string,
69
- snapshot: Snapshot,
70
- current: Snapshot,
71
- options: { sessionDir?: string } = {},
55
+ root: string,
56
+ snapshot: Snapshot,
57
+ current: Snapshot,
58
+ options: { sessionDir?: string } = {},
72
59
  ): SnapshotApplyPlan {
73
- const seenPaths = new Set<string>();
74
- const remotePaths = new Set<string>();
75
- const writes: Array<{ target: string; content: Buffer }> = [];
76
- const deletes: string[] = [];
60
+ const seenPaths = new Set<string>();
61
+ const remotePaths = new Set<string>();
62
+ const writes: Array<{ target: string; content: Buffer }> = [];
63
+ const deletes: string[] = [];
77
64
 
78
- for (const file of snapshot.files) {
79
- const normalized = toPosix(file.path);
80
- if (!isSafeSnapshotPath(file.path)) {
81
- throw new Error(`Unsafe path in snapshot: ${file.path}`);
82
- }
83
- if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
84
- throw new Error(`Unsafe session path in snapshot: ${file.path}`);
85
- }
86
- if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
87
- seenPaths.add(normalized);
88
- remotePaths.add(normalized);
65
+ for (const file of snapshot.files) {
66
+ const normalized = toPosix(file.path);
67
+ if (!isSafeSnapshotPath(file.path)) {
68
+ throw new Error(`Unsafe path in snapshot: ${file.path}`);
69
+ }
70
+ if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
71
+ throw new Error(`Unsafe session path in snapshot: ${file.path}`);
72
+ }
73
+ if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
74
+ seenPaths.add(normalized);
75
+ remotePaths.add(normalized);
89
76
 
90
- const target = snapshotTarget(root, normalized, options.sessionDir);
91
- const content = decodeBase64Strict(file.contentBase64, normalized);
92
- if (sha256(content) !== file.sha256)
93
- throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
94
- writes.push({ target, content });
95
- }
77
+ const target = snapshotTarget(root, normalized, options.sessionDir);
78
+ const content = decodeBase64Strict(file.contentBase64, normalized);
79
+ if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
80
+ writes.push({ target, content });
81
+ }
96
82
 
97
- const deletePaths = new Set<string>();
98
- for (const file of current.files) {
99
- const normalized = toPosix(file.path);
100
- if (!remotePaths.has(normalized)) {
101
- deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
102
- }
103
- for (const remotePath of parentPaths(normalized)) {
104
- if (remotePaths.has(remotePath)) {
105
- deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
106
- }
107
- }
108
- }
109
- deletes.push(...deletePaths);
83
+ const deletePaths = new Set<string>();
84
+ for (const file of current.files) {
85
+ const normalized = toPosix(file.path);
86
+ if (!remotePaths.has(normalized)) {
87
+ deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
88
+ }
89
+ for (const remotePath of parentPaths(normalized)) {
90
+ if (remotePaths.has(remotePath)) {
91
+ deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
92
+ }
93
+ }
94
+ }
95
+ deletes.push(...deletePaths);
110
96
 
111
- return { writes, deletes };
97
+ return { writes, deletes };
112
98
  }
113
99
 
114
100
  export function protectSnapshotApplyPlan(
115
- root: string,
116
- plan: SnapshotApplyPlan,
117
- protectedRelativePaths: Set<string>,
118
- sessionDir?: string,
101
+ root: string,
102
+ plan: SnapshotApplyPlan,
103
+ protectedRelativePaths: Set<string>,
104
+ sessionDir?: string,
119
105
  ): SnapshotApplyPlan {
120
- if (protectedRelativePaths.size === 0) return plan;
121
- const protectedTargets = new Set(
122
- [...protectedRelativePaths].map((relativePath) =>
123
- snapshotTarget(root, relativePath, sessionDir),
124
- ),
125
- );
126
- return {
127
- writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
128
- deletes: plan.deletes.filter((target) => !protectedTargets.has(target)),
129
- };
106
+ if (protectedRelativePaths.size === 0) return plan;
107
+ const protectedTargets = new Set(
108
+ [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir)),
109
+ );
110
+ return {
111
+ writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
112
+ deletes: plan.deletes.filter((target) => !protectedTargets.has(target)),
113
+ };
130
114
  }
131
115
 
132
116
  export async function addTopLevelCaseVariantDeletes(
133
- root: string,
134
- plan: SnapshotApplyPlan,
135
- snapshot: Pick<Snapshot, "files">,
117
+ root: string,
118
+ plan: SnapshotApplyPlan,
119
+ snapshot: Pick<Snapshot, "files">,
136
120
  ): Promise<SnapshotApplyPlan> {
137
- const topLevelPaths = new Map<string, string>();
138
- for (const file of snapshot.files) {
139
- const normalized = toPosix(file.path);
140
- if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
141
- topLevelPaths.set(normalized.toLowerCase(), normalized);
142
- }
143
- }
144
- if (topLevelPaths.size === 0) return plan;
121
+ const topLevelPaths = new Map<string, string>();
122
+ for (const file of snapshot.files) {
123
+ const normalized = toPosix(file.path);
124
+ if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
125
+ topLevelPaths.set(normalized.toLowerCase(), normalized);
126
+ }
127
+ }
128
+ if (topLevelPaths.size === 0) return plan;
145
129
 
146
- let entries: Dirent[];
147
- try {
148
- entries = await fs.readdir(root, { withFileTypes: true });
149
- } catch (error) {
150
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return plan;
151
- throw error;
152
- }
130
+ let entries: Dirent[];
131
+ try {
132
+ entries = await fs.readdir(root, { withFileTypes: true });
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return plan;
135
+ throw error;
136
+ }
153
137
 
154
- const deletes = new Set(plan.deletes);
155
- for (const entry of entries) {
156
- const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
157
- if (
158
- canonicalPath &&
159
- entry.name !== canonicalPath &&
160
- (entry.isFile() || entry.isSymbolicLink())
161
- ) {
162
- deletes.add(safeJoin(root, entry.name));
163
- }
164
- }
165
- return { ...plan, deletes: [...deletes] };
138
+ const deletes = new Set(plan.deletes);
139
+ for (const entry of entries) {
140
+ const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
141
+ if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
142
+ deletes.add(safeJoin(root, entry.name));
143
+ }
144
+ }
145
+ return { ...plan, deletes: [...deletes] };
166
146
  }
167
147
 
168
- export function appliedFileHashMap(
169
- snapshot: Snapshot,
170
- current: Snapshot,
171
- protectedRelativePaths: Set<string>,
172
- ) {
173
- const hashes = fileHashMap(snapshot);
174
- if (protectedRelativePaths.size === 0) return hashes;
175
- const currentHashes = fileHashMap(current);
176
- for (const relativePath of protectedRelativePaths) {
177
- const normalized = toPosix(relativePath);
178
- if (currentHashes[normalized]) {
179
- hashes[normalized] = currentHashes[normalized];
180
- } else {
181
- delete hashes[normalized];
182
- }
183
- }
184
- return hashes;
148
+ export function appliedFileHashMap(snapshot: Snapshot, current: Snapshot, protectedRelativePaths: Set<string>) {
149
+ const hashes = fileHashMap(snapshot);
150
+ if (protectedRelativePaths.size === 0) return hashes;
151
+ const currentHashes = fileHashMap(current);
152
+ for (const relativePath of protectedRelativePaths) {
153
+ const normalized = toPosix(relativePath);
154
+ if (currentHashes[normalized]) {
155
+ hashes[normalized] = currentHashes[normalized];
156
+ } else {
157
+ delete hashes[normalized];
158
+ }
159
+ }
160
+ return hashes;
185
161
  }
186
162
 
187
163
  function isSafeSnapshotPath(relativePath: string) {
188
- if (relativePath.includes("\\")) return false;
189
- const normalized = toPosix(relativePath);
190
- return (
191
- Boolean(normalized) &&
192
- normalized !== "." &&
193
- normalized !== ".." &&
194
- !normalized.startsWith("../") &&
195
- !path.posix.isAbsolute(normalized) &&
196
- path.posix.normalize(normalized) === normalized &&
197
- !isDeniedPath(normalized)
198
- );
164
+ if (relativePath.includes("\\")) return false;
165
+ const normalized = toPosix(relativePath);
166
+ return (
167
+ Boolean(normalized) &&
168
+ normalized !== "." &&
169
+ normalized !== ".." &&
170
+ !normalized.startsWith("../") &&
171
+ !path.posix.isAbsolute(normalized) &&
172
+ path.posix.normalize(normalized) === normalized &&
173
+ !isDeniedPath(normalized)
174
+ );
199
175
  }
200
176
 
201
177
  function decodeBase64Strict(value: string, filePath: string) {
202
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
203
- throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
204
- }
205
- return Buffer.from(value, "base64");
178
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
179
+ throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
180
+ }
181
+ return Buffer.from(value, "base64");
206
182
  }
207
183
 
208
184
  async function preflightSnapshotMutations(
209
- root: string,
210
- plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
211
- sessionDir?: string,
185
+ root: string,
186
+ plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
187
+ sessionDir?: string,
212
188
  ) {
213
- const deletePaths = new Set(plan.deletes);
214
- for (const target of plan.deletes) {
215
- await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
216
- }
217
- for (const item of plan.writes) {
218
- await prepareSnapshotWrite(
219
- rootForTarget(root, item.target, sessionDir),
220
- item.target,
221
- deletePaths,
222
- );
223
- }
189
+ const deletePaths = new Set(plan.deletes);
190
+ for (const target of plan.deletes) {
191
+ await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
192
+ }
193
+ for (const item of plan.writes) {
194
+ await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
195
+ }
224
196
  }
225
197
 
226
198
  function rootForTarget(root: string, target: string, sessionDir?: string) {
227
- const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : undefined;
228
- if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
229
- return root;
199
+ const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : undefined;
200
+ if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
201
+ return root;
230
202
  }
231
203
 
232
204
  async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>) {
233
- const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths);
234
- if (parentWillBeReplaced) return;
235
- try {
236
- const stat = await fs.lstat(target);
237
- if (stat.isSymbolicLink())
238
- throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
239
- if (stat.isDirectory() && !deletePaths.has(target)) {
240
- throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
241
- }
242
- } catch (error) {
243
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
244
- }
205
+ const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths);
206
+ if (parentWillBeReplaced) return;
207
+ try {
208
+ const stat = await fs.lstat(target);
209
+ if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
210
+ if (stat.isDirectory() && !deletePaths.has(target)) {
211
+ throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
212
+ }
213
+ } catch (error) {
214
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
215
+ }
245
216
  }
246
217
 
247
218
  async function ensureSafeDirectory(root: string, directory: string, deletePaths: Set<string>) {
248
- assertWithinRoot(root, directory);
249
- const rootPath = path.resolve(root);
250
- const relative = path.relative(rootPath, path.resolve(directory));
251
- let current = rootPath;
252
- for (const part of relative.split(path.sep).filter(Boolean)) {
253
- current = path.join(current, part);
254
- try {
255
- const stat = await fs.lstat(current);
256
- if (stat.isSymbolicLink())
257
- throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
258
- if (!stat.isDirectory()) {
259
- if (deletePaths.has(current)) return true;
260
- throw new Error(`Snapshot path parent is not a directory: ${current}`);
261
- }
262
- } catch (error) {
263
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
264
- await fs.mkdir(current);
265
- }
266
- }
267
- return false;
219
+ assertWithinRoot(root, directory);
220
+ const rootPath = path.resolve(root);
221
+ const relative = path.relative(rootPath, path.resolve(directory));
222
+ let current = rootPath;
223
+ for (const part of relative.split(path.sep).filter(Boolean)) {
224
+ current = path.join(current, part);
225
+ try {
226
+ const stat = await fs.lstat(current);
227
+ if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
228
+ if (!stat.isDirectory()) {
229
+ if (deletePaths.has(current)) return true;
230
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
231
+ }
232
+ } catch (error) {
233
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
234
+ await fs.mkdir(current);
235
+ }
236
+ }
237
+ return false;
268
238
  }
269
239
 
270
240
  async function assertNoSymlinkParents(root: string, target: string) {
271
- assertWithinRoot(root, target);
272
- const rootPath = path.resolve(root);
273
- const relative = path.relative(rootPath, path.resolve(target));
274
- let current = rootPath;
275
- const parts = relative.split(path.sep).filter(Boolean);
276
- for (const part of parts.slice(0, -1)) {
277
- current = path.join(current, part);
278
- try {
279
- const stat = await fs.lstat(current);
280
- if (stat.isSymbolicLink())
281
- throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
282
- if (!stat.isDirectory())
283
- throw new Error(`Snapshot path parent is not a directory: ${current}`);
284
- } catch (error) {
285
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
286
- throw error;
287
- }
288
- }
241
+ assertWithinRoot(root, target);
242
+ const rootPath = path.resolve(root);
243
+ const relative = path.relative(rootPath, path.resolve(target));
244
+ let current = rootPath;
245
+ const parts = relative.split(path.sep).filter(Boolean);
246
+ for (const part of parts.slice(0, -1)) {
247
+ current = path.join(current, part);
248
+ try {
249
+ const stat = await fs.lstat(current);
250
+ if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
251
+ if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
252
+ } catch (error) {
253
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
254
+ throw error;
255
+ }
256
+ }
289
257
  }