@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
@@ -10,42 +10,40 @@ const MAX_DECOMPRESSED_SNAPSHOT_BYTES = 512 * 1024 * 1024;
10
10
  const gzipAsync = promisify(gzip);
11
11
 
12
12
  export async function encodeSnapshot(snapshot: Snapshot) {
13
- snapshotSelectionInclude(snapshot);
14
- return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
13
+ snapshotSelectionInclude(snapshot);
14
+ return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
15
15
  }
16
16
 
17
17
  export async function decodeSnapshot(
18
- buffer: Buffer,
19
- options: { signal?: AbortSignal; maxOutputLength?: number } = {},
18
+ buffer: Buffer,
19
+ options: { signal?: AbortSignal; maxOutputLength?: number } = {},
20
20
  ): Promise<Snapshot> {
21
- throwIfAborted(options.signal);
22
- const limit = options.maxOutputLength ?? MAX_DECOMPRESSED_SNAPSHOT_BYTES;
23
- const chunks: Buffer[] = [];
24
- let total = 0;
25
- const sink = new Writable({
26
- write(chunk: Buffer, _encoding, callback) {
27
- total += chunk.byteLength;
28
- if (total > limit) {
29
- callback(new Error(`Decompressed snapshot exceeds the ${limit}-byte limit.`));
30
- return;
31
- }
32
- chunks.push(Buffer.from(chunk));
33
- callback();
34
- },
35
- });
36
- await pipeline(Readable.from([buffer]), createGunzip(), sink, { signal: options.signal });
37
- throwIfAborted(options.signal);
38
- const parsed = JSON.parse(Buffer.concat(chunks, total).toString("utf8")) as Snapshot;
39
- if (parsed.version !== VERSION || !Array.isArray(parsed.files)) {
40
- throw new Error("Unsupported snapshot format.");
41
- }
42
- snapshotSelectionInclude(parsed);
43
- return parsed;
21
+ throwIfAborted(options.signal);
22
+ const limit = options.maxOutputLength ?? MAX_DECOMPRESSED_SNAPSHOT_BYTES;
23
+ const chunks: Buffer[] = [];
24
+ let total = 0;
25
+ const sink = new Writable({
26
+ write(chunk: Buffer, _encoding, callback) {
27
+ total += chunk.byteLength;
28
+ if (total > limit) {
29
+ callback(new Error(`Decompressed snapshot exceeds the ${limit}-byte limit.`));
30
+ return;
31
+ }
32
+ chunks.push(Buffer.from(chunk));
33
+ callback();
34
+ },
35
+ });
36
+ await pipeline(Readable.from([buffer]), createGunzip(), sink, { signal: options.signal });
37
+ throwIfAborted(options.signal);
38
+ const parsed = JSON.parse(Buffer.concat(chunks, total).toString("utf8")) as Snapshot;
39
+ if (parsed.version !== VERSION || !Array.isArray(parsed.files)) {
40
+ throw new Error("Unsupported snapshot format.");
41
+ }
42
+ snapshotSelectionInclude(parsed);
43
+ return parsed;
44
44
  }
45
45
 
46
46
  function throwIfAborted(signal?: AbortSignal) {
47
- if (!signal?.aborted) return;
48
- throw signal.reason instanceof Error
49
- ? signal.reason
50
- : new DOMException("The operation was aborted", "AbortError");
47
+ if (!signal?.aborted) return;
48
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
51
49
  }
@@ -1,13 +1,11 @@
1
1
  import path from "node:path";
2
2
 
3
3
  function expandHome(value: string) {
4
- if (value === "~") return process.env.HOME ?? value;
5
- if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
6
- return value;
4
+ if (value === "~") return process.env.HOME ?? value;
5
+ if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
6
+ return value;
7
7
  }
8
8
 
9
9
  export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
10
- return configuredSessionDir
11
- ? path.resolve(expandHome(configuredSessionDir))
12
- : path.resolve(root, "sessions");
10
+ return configuredSessionDir ? path.resolve(expandHome(configuredSessionDir)) : path.resolve(root, "sessions");
13
11
  }
@@ -11,203 +11,194 @@ import type { SnapshotApplyPlan } from "./snapshot-types.js";
11
11
  const JOURNAL_VERSION = 1;
12
12
 
13
13
  interface TransactionEntry {
14
- target: string;
15
- backupName: string;
16
- kind: "missing" | "file" | "directory" | "symlink";
17
- linkTarget?: string;
14
+ target: string;
15
+ backupName: string;
16
+ kind: "missing" | "file" | "directory" | "symlink";
17
+ linkTarget?: string;
18
18
  }
19
19
 
20
20
  interface TransactionJournal {
21
- version: number;
22
- root: string;
23
- sessionRoot?: string;
24
- entries: TransactionEntry[];
21
+ version: number;
22
+ root: string;
23
+ sessionRoot?: string;
24
+ entries: TransactionEntry[];
25
25
  }
26
26
 
27
- export async function applySnapshotTransaction(
28
- plan: SnapshotApplyPlan,
29
- options: { sessionDir?: string } = {},
30
- ) {
31
- await recoverPendingSnapshotTransactions();
32
- const transaction = await prepareTransaction(plan, options.sessionDir);
33
- try {
34
- for (const target of plan.deletes) {
35
- await fs.rm(target, { force: true, recursive: true });
36
- }
37
- for (const item of plan.writes) {
38
- await fs.mkdir(path.dirname(item.target), { recursive: true });
39
- await fs.writeFile(item.target, item.content);
40
- }
41
- await fs.rm(transaction.directory, { recursive: true, force: true });
42
- } catch (error) {
43
- try {
44
- await restoreTransaction(transaction.directory, transaction.journal);
45
- } catch (recoveryError) {
46
- throw new AggregateError(
47
- [error, recoveryError],
48
- `Snapshot apply failed and automatic recovery also failed. Transaction retained at ${transaction.directory}.`,
49
- );
50
- }
51
- throw error;
52
- }
27
+ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options: { sessionDir?: string } = {}) {
28
+ await recoverPendingSnapshotTransactions();
29
+ const transaction = await prepareTransaction(plan, options.sessionDir);
30
+ try {
31
+ for (const target of plan.deletes) {
32
+ await fs.rm(target, { force: true, recursive: true });
33
+ }
34
+ for (const item of plan.writes) {
35
+ await fs.mkdir(path.dirname(item.target), { recursive: true });
36
+ await fs.writeFile(item.target, item.content);
37
+ }
38
+ await fs.rm(transaction.directory, { recursive: true, force: true });
39
+ } catch (error) {
40
+ try {
41
+ await restoreTransaction(transaction.directory, transaction.journal);
42
+ } catch (recoveryError) {
43
+ throw new AggregateError(
44
+ [error, recoveryError],
45
+ `Snapshot apply failed and automatic recovery also failed. Transaction retained at ${transaction.directory}.`,
46
+ );
47
+ }
48
+ throw error;
49
+ }
53
50
  }
54
51
 
55
52
  export async function recoverSnapshotTransactionsOnStartup() {
56
- if (!(await pendingTransactionEntries()).some((entry) => entry.isDirectory())) return;
57
- await withLock("recovery", recoverPendingSnapshotTransactions, { reclaimStale: true });
53
+ if (!(await pendingTransactionEntries()).some((entry) => entry.isDirectory())) return;
54
+ await withLock("recovery", recoverPendingSnapshotTransactions, { reclaimStale: true });
58
55
  }
59
56
 
60
57
  export async function recoverPendingSnapshotTransactions() {
61
- const directory = transactionRoot();
62
- const entries = await pendingTransactionEntries();
63
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
64
- if (!entry.isDirectory()) continue;
65
- const transactionDirectory = path.join(directory, entry.name);
66
- const journalPath = path.join(transactionDirectory, "journal.json");
67
- let journal: TransactionJournal;
68
- try {
69
- journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as TransactionJournal;
70
- } catch (error) {
71
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
72
- await fs.rm(transactionDirectory, { recursive: true, force: true });
73
- continue;
74
- }
75
- throw new Error(`Cannot recover malformed pi-sync transaction: ${journalPath}`, {
76
- cause: error,
77
- });
78
- }
79
- await restoreTransaction(transactionDirectory, journal);
80
- }
58
+ const directory = transactionRoot();
59
+ const entries = await pendingTransactionEntries();
60
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
61
+ if (!entry.isDirectory()) continue;
62
+ const transactionDirectory = path.join(directory, entry.name);
63
+ const journalPath = path.join(transactionDirectory, "journal.json");
64
+ let journal: TransactionJournal;
65
+ try {
66
+ journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as TransactionJournal;
67
+ } catch (error) {
68
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
69
+ await fs.rm(transactionDirectory, { recursive: true, force: true });
70
+ continue;
71
+ }
72
+ throw new Error(`Cannot recover malformed pi-sync transaction: ${journalPath}`, {
73
+ cause: error,
74
+ });
75
+ }
76
+ await restoreTransaction(transactionDirectory, journal);
77
+ }
81
78
  }
82
79
 
83
80
  async function pendingTransactionEntries() {
84
- try {
85
- return await fs.readdir(transactionRoot(), { withFileTypes: true });
86
- } catch (error) {
87
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
88
- throw error;
89
- }
81
+ try {
82
+ return await fs.readdir(transactionRoot(), { withFileTypes: true });
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
85
+ throw error;
86
+ }
90
87
  }
91
88
 
92
89
  async function prepareTransaction(plan: SnapshotApplyPlan, sessionDir?: string) {
93
- const root = path.resolve(agentDir());
94
- const sessionRoot = sessionDir ? path.resolve(sessionStorageRoot(root, sessionDir)) : undefined;
95
- const directory = path.join(transactionRoot(), randomUUID());
96
- const backupDirectory = path.join(directory, "before");
97
- await fs.mkdir(backupDirectory, { recursive: true, mode: 0o700 });
98
- const targets = [...new Set([...plan.deletes, ...plan.writes.map((item) => item.target)])].sort();
99
- const entries: TransactionEntry[] = [];
100
- for (let index = 0; index < targets.length; index += 1) {
101
- const target = targets[index];
102
- if (!target) continue;
103
- assertAllowedTarget(root, sessionRoot, target);
104
- const backupName = `${index}`;
105
- const backupPath = path.join(backupDirectory, backupName);
106
- try {
107
- const stat = await fs.lstat(target);
108
- if (stat.isSymbolicLink()) {
109
- entries.push({
110
- target,
111
- backupName,
112
- kind: "symlink",
113
- linkTarget: await fs.readlink(target),
114
- });
115
- } else if (stat.isDirectory()) {
116
- await fs.cp(target, backupPath, {
117
- recursive: true,
118
- dereference: false,
119
- preserveTimestamps: true,
120
- });
121
- entries.push({ target, backupName, kind: "directory" });
122
- } else if (stat.isFile()) {
123
- await fs.copyFile(target, backupPath);
124
- await fs.chmod(backupPath, stat.mode);
125
- entries.push({ target, backupName, kind: "file" });
126
- } else {
127
- throw new Error(`Unsupported existing snapshot target: ${target}`);
128
- }
129
- } catch (error) {
130
- const code = (error as NodeJS.ErrnoException).code;
131
- if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
132
- entries.push({ target, backupName, kind: "missing" });
133
- }
134
- }
135
- const journal: TransactionJournal = {
136
- version: JOURNAL_VERSION,
137
- root,
138
- sessionRoot,
139
- entries,
140
- };
141
- await fs.writeFile(
142
- path.join(directory, "journal.json"),
143
- `${JSON.stringify(journal, null, "\t")}\n`,
144
- {
145
- mode: 0o600,
146
- },
147
- );
148
- return { directory, journal };
90
+ const root = path.resolve(agentDir());
91
+ const sessionRoot = sessionDir ? path.resolve(sessionStorageRoot(root, sessionDir)) : undefined;
92
+ const directory = path.join(transactionRoot(), randomUUID());
93
+ const backupDirectory = path.join(directory, "before");
94
+ await fs.mkdir(backupDirectory, { recursive: true, mode: 0o700 });
95
+ const targets = [...new Set([...plan.deletes, ...plan.writes.map((item) => item.target)])].sort();
96
+ const entries: TransactionEntry[] = [];
97
+ for (let index = 0; index < targets.length; index += 1) {
98
+ const target = targets[index];
99
+ if (!target) continue;
100
+ assertAllowedTarget(root, sessionRoot, target);
101
+ const backupName = `${index}`;
102
+ const backupPath = path.join(backupDirectory, backupName);
103
+ try {
104
+ const stat = await fs.lstat(target);
105
+ if (stat.isSymbolicLink()) {
106
+ entries.push({
107
+ target,
108
+ backupName,
109
+ kind: "symlink",
110
+ linkTarget: await fs.readlink(target),
111
+ });
112
+ } else if (stat.isDirectory()) {
113
+ await fs.cp(target, backupPath, {
114
+ recursive: true,
115
+ dereference: false,
116
+ preserveTimestamps: true,
117
+ });
118
+ entries.push({ target, backupName, kind: "directory" });
119
+ } else if (stat.isFile()) {
120
+ await fs.copyFile(target, backupPath);
121
+ await fs.chmod(backupPath, stat.mode);
122
+ entries.push({ target, backupName, kind: "file" });
123
+ } else {
124
+ throw new Error(`Unsupported existing snapshot target: ${target}`);
125
+ }
126
+ } catch (error) {
127
+ const code = (error as NodeJS.ErrnoException).code;
128
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
129
+ entries.push({ target, backupName, kind: "missing" });
130
+ }
131
+ }
132
+ const journal: TransactionJournal = {
133
+ version: JOURNAL_VERSION,
134
+ root,
135
+ sessionRoot,
136
+ entries,
137
+ };
138
+ await fs.writeFile(path.join(directory, "journal.json"), `${JSON.stringify(journal, null, "\t")}\n`, {
139
+ mode: 0o600,
140
+ });
141
+ return { directory, journal };
149
142
  }
150
143
 
151
144
  async function restoreTransaction(directory: string, journal: TransactionJournal) {
152
- validateJournal(directory, journal);
153
- for (const entry of [...journal.entries].sort(
154
- (left, right) => right.target.length - left.target.length,
155
- )) {
156
- await fs.rm(entry.target, { recursive: true, force: true });
157
- }
158
- for (const entry of journal.entries) {
159
- if (entry.kind === "missing") continue;
160
- await fs.mkdir(path.dirname(entry.target), { recursive: true });
161
- const backupPath = path.join(directory, "before", entry.backupName);
162
- assertWithinRoot(directory, backupPath);
163
- if (entry.kind === "file") await fs.copyFile(backupPath, entry.target);
164
- else if (entry.kind === "directory") {
165
- await fs.cp(backupPath, entry.target, {
166
- recursive: true,
167
- dereference: false,
168
- preserveTimestamps: true,
169
- });
170
- } else if (entry.kind === "symlink" && entry.linkTarget !== undefined) {
171
- await fs.symlink(entry.linkTarget, entry.target);
172
- }
173
- }
174
- await fs.rm(directory, { recursive: true, force: true });
145
+ validateJournal(directory, journal);
146
+ for (const entry of [...journal.entries].sort((left, right) => right.target.length - left.target.length)) {
147
+ await fs.rm(entry.target, { recursive: true, force: true });
148
+ }
149
+ for (const entry of journal.entries) {
150
+ if (entry.kind === "missing") continue;
151
+ await fs.mkdir(path.dirname(entry.target), { recursive: true });
152
+ const backupPath = path.join(directory, "before", entry.backupName);
153
+ assertWithinRoot(directory, backupPath);
154
+ if (entry.kind === "file") await fs.copyFile(backupPath, entry.target);
155
+ else if (entry.kind === "directory") {
156
+ await fs.cp(backupPath, entry.target, {
157
+ recursive: true,
158
+ dereference: false,
159
+ preserveTimestamps: true,
160
+ });
161
+ } else if (entry.kind === "symlink" && entry.linkTarget !== undefined) {
162
+ await fs.symlink(entry.linkTarget, entry.target);
163
+ }
164
+ }
165
+ await fs.rm(directory, { recursive: true, force: true });
175
166
  }
176
167
 
177
168
  function validateJournal(directory: string, journal: TransactionJournal) {
178
- if (journal.version !== JOURNAL_VERSION || !Array.isArray(journal.entries)) {
179
- throw new Error(`Unsupported pi-sync transaction journal: ${directory}`);
180
- }
181
- const expectedRoot = path.resolve(agentDir());
182
- if (path.resolve(journal.root) !== expectedRoot) {
183
- throw new Error(`Transaction root no longer matches the Pi agent directory: ${directory}`);
184
- }
185
- for (const entry of journal.entries) {
186
- if (
187
- !entry ||
188
- typeof entry.target !== "string" ||
189
- typeof entry.backupName !== "string" ||
190
- !/^\d+$/u.test(entry.backupName)
191
- ) {
192
- throw new Error(`Invalid pi-sync transaction entry: ${directory}`);
193
- }
194
- assertAllowedTarget(journal.root, journal.sessionRoot, entry.target);
195
- }
169
+ if (journal.version !== JOURNAL_VERSION || !Array.isArray(journal.entries)) {
170
+ throw new Error(`Unsupported pi-sync transaction journal: ${directory}`);
171
+ }
172
+ const expectedRoot = path.resolve(agentDir());
173
+ if (path.resolve(journal.root) !== expectedRoot) {
174
+ throw new Error(`Transaction root no longer matches the Pi agent directory: ${directory}`);
175
+ }
176
+ for (const entry of journal.entries) {
177
+ if (
178
+ !entry ||
179
+ typeof entry.target !== "string" ||
180
+ typeof entry.backupName !== "string" ||
181
+ !/^\d+$/u.test(entry.backupName)
182
+ ) {
183
+ throw new Error(`Invalid pi-sync transaction entry: ${directory}`);
184
+ }
185
+ assertAllowedTarget(journal.root, journal.sessionRoot, entry.target);
186
+ }
196
187
  }
197
188
 
198
189
  function assertAllowedTarget(root: string, sessionRoot: string | undefined, target: string) {
199
- const resolved = path.resolve(target);
200
- if (isPathInside(root, resolved)) {
201
- assertWithinRoot(root, resolved);
202
- return;
203
- }
204
- if (sessionRoot && isPathInside(sessionRoot, resolved)) {
205
- assertWithinRoot(sessionRoot, resolved);
206
- return;
207
- }
208
- throw new Error(`Transaction target is outside configured roots: ${target}`);
190
+ const resolved = path.resolve(target);
191
+ if (isPathInside(root, resolved)) {
192
+ assertWithinRoot(root, resolved);
193
+ return;
194
+ }
195
+ if (sessionRoot && isPathInside(sessionRoot, resolved)) {
196
+ assertWithinRoot(sessionRoot, resolved);
197
+ return;
198
+ }
199
+ throw new Error(`Transaction target is outside configured roots: ${target}`);
209
200
  }
210
201
 
211
202
  function transactionRoot() {
212
- return path.join(stateDir(), "transactions");
203
+ return path.join(stateDir(), "transactions");
213
204
  }
@@ -1,38 +1,38 @@
1
1
  export interface SnapshotFile {
2
- path: string;
3
- contentBase64: string;
4
- sha256: string;
2
+ path: string;
3
+ contentBase64: string;
4
+ sha256: string;
5
5
  }
6
6
 
7
7
  export interface SnapshotSelection {
8
- version: 1;
9
- include: string[];
8
+ version: 1;
9
+ include: string[];
10
10
  }
11
11
 
12
12
  export interface Snapshot {
13
- version: number;
14
- id: string;
15
- createdAt: string;
16
- machine: string;
17
- /** Backend-scoped remote identity retained in the snapshot wire format. */
18
- profile: string;
19
- syncSessions?: boolean;
20
- /** Portable, credential-free included-content intent. Absent on legacy snapshots. */
21
- selection?: SnapshotSelection;
22
- files: SnapshotFile[];
13
+ version: number;
14
+ id: string;
15
+ createdAt: string;
16
+ machine: string;
17
+ /** Backend-scoped remote identity retained in the snapshot wire format. */
18
+ profile: string;
19
+ syncSessions?: boolean;
20
+ /** Portable, credential-free included-content intent. Absent on legacy snapshots. */
21
+ selection?: SnapshotSelection;
22
+ files: SnapshotFile[];
23
23
  }
24
24
 
25
25
  export interface SnapshotOptions {
26
- signal?: AbortSignal;
27
- include?: string[];
28
- sessionDir?: string;
29
- /** Temporary internal projections while snapshot storage remains wire-compatible. */
30
- syncFiles?: string[];
31
- syncSessions?: boolean;
32
- extraFiles?: string[];
26
+ signal?: AbortSignal;
27
+ include?: string[];
28
+ sessionDir?: string;
29
+ /** Temporary internal projections while snapshot storage remains wire-compatible. */
30
+ syncFiles?: string[];
31
+ syncSessions?: boolean;
32
+ extraFiles?: string[];
33
33
  }
34
34
 
35
35
  export interface SnapshotApplyPlan {
36
- writes: Array<{ target: string; content: Buffer }>;
37
- deletes: string[];
36
+ writes: Array<{ target: string; content: Buffer }>;
37
+ deletes: string[];
38
38
  }