@narumitw/pi-sync 0.49.15 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +49 -16
  2. package/dist/chunks/attention-widget-Z7G3SYYJ.ts +9 -0
  3. package/dist/chunks/attention-widget-Z7G3SYYJ.ts.map +7 -0
  4. package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
  5. package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
  6. package/dist/chunks/{chunk-ARN2672F.ts → chunk-4CHTZMEO.ts} +23 -55
  7. package/dist/chunks/chunk-4CHTZMEO.ts.map +7 -0
  8. package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
  9. package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
  10. package/dist/chunks/{chunk-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
  11. package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
  12. package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
  13. package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
  14. package/dist/chunks/chunk-CCSN3F47.ts +51 -0
  15. package/dist/chunks/chunk-CCSN3F47.ts.map +7 -0
  16. package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
  17. package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
  18. package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
  19. package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
  20. package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
  21. package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
  22. package/dist/chunks/{chunk-WSOBYHIZ.ts → chunk-ETTRJDNP.ts} +4 -4
  23. package/dist/chunks/chunk-ETTRJDNP.ts.map +7 -0
  24. package/dist/chunks/{chunk-44LPGQTI.ts → chunk-G4RNQQZS.ts} +9 -7
  25. package/dist/chunks/chunk-G4RNQQZS.ts.map +7 -0
  26. package/dist/chunks/{chunk-5LZALPCD.ts → chunk-HUDQATCL.ts} +25 -25
  27. package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
  28. package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-IJ6FJFA5.ts} +9 -9
  29. package/dist/chunks/chunk-IJ6FJFA5.ts.map +7 -0
  30. package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
  31. package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
  32. package/dist/chunks/chunk-KW7YQEZN.ts +1255 -0
  33. package/dist/chunks/chunk-KW7YQEZN.ts.map +7 -0
  34. package/dist/chunks/{chunk-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -8
  35. package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
  36. package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
  37. package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
  38. package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
  39. package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
  40. package/dist/chunks/{chunk-HVUSHMVL.ts → chunk-OEX4ABY6.ts} +39 -37
  41. package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
  42. package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
  43. package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
  44. package/dist/chunks/chunk-S7YBZR42.ts +146 -0
  45. package/dist/chunks/chunk-S7YBZR42.ts.map +7 -0
  46. package/dist/chunks/{chunk-GLQLN5GV.ts → chunk-SL64RLXR.ts} +8 -6
  47. package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
  48. package/dist/chunks/chunk-SZWBGPID.ts +57 -0
  49. package/dist/chunks/chunk-SZWBGPID.ts.map +7 -0
  50. package/dist/chunks/chunk-T2B5IU74.ts +25 -0
  51. package/dist/chunks/chunk-T2B5IU74.ts.map +7 -0
  52. package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
  53. package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
  54. package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
  55. package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
  56. package/dist/chunks/chunk-THMF4BVL.ts +18 -0
  57. package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
  58. package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
  59. package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
  60. package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
  61. package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
  62. package/dist/chunks/{file-selection-RKEHALNQ.ts → file-selection-FTTKAOYX.ts} +10 -7
  63. package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
  64. package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-GMVS4PD4.ts} +39 -14
  65. package/dist/chunks/git-backend-GMVS4PD4.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +22 -0
  67. package/dist/chunks/manager-ui-R3XPUFFS.ts +1680 -0
  68. package/dist/chunks/manager-ui-R3XPUFFS.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +21 -0
  70. package/dist/chunks/{s3-backend-552LXHE4.ts → s3-backend-66O4FGBH.ts} +94 -16
  71. package/dist/chunks/s3-backend-66O4FGBH.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-5YDG5YGJ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
  73. package/dist/chunks/setup-wizard-IUB6ESEU.ts +89 -0
  74. package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +7 -0
  75. package/dist/chunks/{snapshot-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
  76. package/dist/chunks/sync-inspection-CST2KL65.ts +20 -0
  77. package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-LBX3GKCM.ts} +369 -289
  78. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +7 -0
  79. package/dist/chunks/{sync-state-SRN6YRBK.ts → sync-state-Q2FTXTW6.ts} +6 -6
  80. package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
  81. package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
  82. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
  83. package/dist/index.ts +476 -341
  84. package/dist/index.ts.map +4 -4
  85. package/docs/settings.md +30 -6
  86. package/package.json +1 -1
  87. package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
  88. package/src/backends/backend-identity.ts +40 -0
  89. package/src/backends/backend-types.ts +81 -0
  90. package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
  91. package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
  92. package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
  93. package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
  94. package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
  95. package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
  96. package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
  97. package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
  98. package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
  99. package/src/commands/command-execution.ts +330 -0
  100. package/src/commands/command-handler.ts +201 -0
  101. package/src/commands/command-types.ts +18 -0
  102. package/src/{command.ts → commands/command.ts} +2 -1
  103. package/src/commands/setup-completions.ts +6 -0
  104. package/src/settings/config.ts +238 -0
  105. package/src/{settings-management.ts → settings/settings-management.ts} +39 -14
  106. package/src/settings/settings-store.ts +109 -0
  107. package/src/settings/settings-types.ts +128 -0
  108. package/src/settings/settings-validation.ts +443 -0
  109. package/src/snapshot/session-paths.ts +84 -0
  110. package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
  111. package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
  112. package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
  113. package/src/snapshot/snapshot-types.ts +38 -0
  114. package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
  115. package/src/state/json-file.ts +20 -0
  116. package/src/{lock.ts → state/lock.ts} +3 -2
  117. package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
  118. package/src/state/state-types.ts +20 -0
  119. package/src/state/sync-state-store.ts +62 -0
  120. package/src/sync/automatic-sync.ts +103 -0
  121. package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
  122. package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
  123. package/src/sync/signals.ts +10 -0
  124. package/src/sync/startup-check.ts +128 -0
  125. package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
  126. package/src/sync/sync-error-guidance.ts +53 -0
  127. package/src/sync/sync-inspection.ts +51 -0
  128. package/src/sync/sync-loaders.ts +54 -0
  129. package/src/{sync-operations.ts → sync/sync-mutations.ts} +34 -325
  130. package/src/sync/sync-operations.ts +12 -0
  131. package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
  132. package/src/sync/sync-queries.ts +235 -0
  133. package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
  134. package/src/sync-extension.ts +64 -708
  135. package/src/sync.ts +8 -8
  136. package/src/types.ts +47 -286
  137. package/src/ui/attention-widget.ts +1 -0
  138. package/src/ui/automatic-sync-summary.ts +6 -0
  139. package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
  140. package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
  141. package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
  142. package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
  143. package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
  144. package/src/ui/manager-ui.ts +386 -0
  145. package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
  146. package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
  147. package/src/{settings-ui.ts → ui/settings-ui.ts} +17 -13
  148. package/src/ui/setup/git-ui.ts +293 -0
  149. package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
  150. package/src/ui/setup/s3-ui.ts +217 -0
  151. package/src/ui/setup/setup-actions.ts +127 -0
  152. package/src/ui/setup/setup-location-ui.ts +167 -0
  153. package/src/ui/setup/setup-name-ui.ts +34 -0
  154. package/src/ui/setup/setup-prompts.ts +92 -0
  155. package/src/ui/setup/setup-review.ts +70 -0
  156. package/src/ui/setup/setup-switcher.ts +181 -0
  157. package/src/ui/setup/setup-wizard.ts +39 -0
  158. package/src/ui/setup/text-input.ts +78 -0
  159. package/src/ui/setup/webdav-ui.ts +330 -0
  160. package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
  161. package/src/ui/sync-attention.ts +187 -0
  162. package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
  163. package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
  164. package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
  165. package/src/ui/terminal-text.ts +4 -0
  166. package/dist/chunks/chunk-44LPGQTI.ts.map +0 -7
  167. package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
  168. package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
  169. package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
  170. package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
  171. package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
  172. package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
  173. package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
  174. package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
  175. package/dist/chunks/chunk-E4COSV32.ts +0 -82
  176. package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
  177. package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
  178. package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
  179. package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
  180. package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
  181. package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
  182. package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
  183. package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
  184. package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
  185. package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
  186. package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
  187. package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
  188. package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
  189. package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
  190. package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
  191. package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
  192. package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
  193. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
  194. package/src/config.ts +0 -923
  195. package/src/git-ui.ts +0 -307
  196. package/src/manager-helpers.ts +0 -64
  197. package/src/manager-ui.ts +0 -997
  198. package/src/sync-attention.ts +0 -100
  199. package/src/webdav-ui.ts +0 -421
  200. /package/dist/chunks/{manager-result-dispatcher-LWPYJAD5.ts.map → manager-result-dispatcher-FKIBM3GO.ts.map} +0 -0
  201. /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-D7RBRJNL.ts.map} +0 -0
  202. /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
  203. /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
  204. /package/dist/chunks/{sync-state-SRN6YRBK.ts.map → sync-inspection-CST2KL65.ts.map} +0 -0
  205. /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
  206. /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
  207. /package/src/{config-file.ts → settings/config-file.ts} +0 -0
  208. /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
  209. /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
  210. /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
  211. /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
  212. /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
  213. /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
package/src/config.ts DELETED
@@ -1,923 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import {
6
- type ExtensionCommandContext,
7
- type ExtensionContext,
8
- getAgentDir,
9
- } from "@earendil-works/pi-coding-agent";
10
- import {
11
- activeLocalConfigPath,
12
- consumeLocalConfigMigrationNotice,
13
- createLocalConfigDocument,
14
- legacyLocalConfigPath,
15
- localConfigPath,
16
- quarantineAndRemoveConfigIfMatches,
17
- readActiveLocalConfigDocumentForRepair,
18
- readMigratingLocalConfigDocument,
19
- replaceLocalConfigDocument,
20
- updateLocalConfigDocument,
21
- } from "./config-file.js";
22
- import {
23
- normalizeGitBranch,
24
- normalizeGitDirectory,
25
- normalizeGitRemote,
26
- normalizeGitRemoteIdentity,
27
- } from "./git-config.js";
28
- import { safeName } from "./paths.js";
29
- import { stateDir } from "./state-directory.js";
30
- import { normalizeSyncInclude } from "./sync-policy.js";
31
- import type {
32
- AnySyncConfig,
33
- OnSwitchAction,
34
- PartialConfig,
35
- PiSyncSettingsV3,
36
- Snapshot,
37
- StorageConnectionSettings,
38
- SyncSetupSettings,
39
- SyncState,
40
- } from "./types.js";
41
- import {
42
- normalizeWebDavIdentityUrl,
43
- normalizeWebDavPath,
44
- normalizeWebDavUrl,
45
- validateWebDavCredentials,
46
- } from "./webdav-config.js";
47
-
48
- const STATE_VERSION = 2;
49
- export const DEFAULT_ON_SWITCH: OnSwitchAction = "ask-before-pull";
50
-
51
- export { normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
52
- export {
53
- activeLocalConfigPath,
54
- consumeLocalConfigMigrationNotice,
55
- createLocalConfigDocument,
56
- legacyLocalConfigPath,
57
- localConfigPath,
58
- normalizeSyncInclude,
59
- quarantineAndRemoveConfigIfMatches,
60
- readActiveLocalConfigDocumentForRepair,
61
- replaceLocalConfigDocument,
62
- updateLocalConfigDocument,
63
- };
64
-
65
- function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
66
- const manager = ctx.sessionManager as typeof ctx.sessionManager & {
67
- usesDefaultSessionDir?: () => boolean;
68
- };
69
- if (manager.usesDefaultSessionDir?.call(manager)) return undefined;
70
- return typeof manager.getSessionDir === "function"
71
- ? (manager.getSessionDir.call(manager) as string | undefined)
72
- : undefined;
73
- }
74
-
75
- export async function loadConfig(setupName?: string): Promise<AnySyncConfig> {
76
- const settings = await requireSettings();
77
- const selectedName = setupName ?? settings.activeSyncSetup;
78
- if (!selectedName) throw new Error("No sync setups are configured.");
79
- validateConfigName(selectedName, "sync setup");
80
- const setup = ownObject<SyncSetupSettings>(settings.syncSetups, selectedName);
81
- if (!setup)
82
- throw new Error(`Invalid pi-sync settings: sync setup “${selectedName}” was not found.`);
83
- const connectionName = setup.storage.connection;
84
- const connection = ownObject<StorageConnectionSettings>(
85
- settings.storageConnections,
86
- connectionName,
87
- );
88
- if (!connection) {
89
- throw new Error(
90
- `Invalid pi-sync settings: sync setup “${selectedName}” references missing storage connection “${connectionName}”.`,
91
- );
92
- }
93
- return resolveSyncConfig(
94
- selectedName,
95
- setup,
96
- connectionName,
97
- connection,
98
- settings.onSwitch,
99
- settings.skipSecretScan ?? false,
100
- );
101
- }
102
-
103
- export type SyncSetupStorageReview = Pick<
104
- PartialConfig,
105
- "connectionName" | "storageKind" | "storagePath" | "bucket" | "branch"
106
- >;
107
-
108
- /** A validated setup-facing projection used by manager and settings UI. */
109
- export async function loadPartialConfig(setupName?: string): Promise<PartialConfig> {
110
- const config = await loadConfig(setupName);
111
- return {
112
- setupName: config.setupName,
113
- ...storageReviewFromConfig(config),
114
- include: [...config.include],
115
- automatic: config.automatic,
116
- onSwitch: config.onSwitch,
117
- };
118
- }
119
-
120
- export function syncSetupStorageReview(
121
- setupName: string,
122
- setup: SyncSetupSettings,
123
- connectionName: string,
124
- connection: StorageConnectionSettings,
125
- ): SyncSetupStorageReview {
126
- return storageReviewFromConfig(
127
- resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH, false),
128
- );
129
- }
130
-
131
- export function syncSetupReviewIdentity(
132
- setupName: string,
133
- setup: SyncSetupSettings,
134
- connectionName: string,
135
- connection: StorageConnectionSettings,
136
- ) {
137
- return syncConfigReviewIdentity(
138
- resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH, false),
139
- );
140
- }
141
-
142
- export function syncConfigReviewIdentity(config: AnySyncConfig) {
143
- return JSON.stringify([
144
- config.setupName,
145
- config.connectionName,
146
- backendIdentityCoordinates(config),
147
- config.include,
148
- config.automatic,
149
- ]);
150
- }
151
-
152
- export function syncConfigReviewFingerprint(config: AnySyncConfig) {
153
- return createHash("sha256").update(syncConfigReviewIdentity(config)).digest("hex");
154
- }
155
-
156
- function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
157
- return {
158
- connectionName: config.connectionName,
159
- storageKind: config.backend.type,
160
- storagePath: config.storagePath,
161
- ...(config.backend.type === "s3"
162
- ? { bucket: config.backend.destination.bucket }
163
- : config.backend.type === "git"
164
- ? { branch: config.backend.destination.branch }
165
- : {}),
166
- };
167
- }
168
-
169
- export async function configuredSyncSetupNames() {
170
- const settings = await readLocalConfigObject();
171
- return settings
172
- ? Object.keys(settings.syncSetups).sort((left, right) => left.localeCompare(right))
173
- : [];
174
- }
175
-
176
- export async function loadOnSwitch(): Promise<OnSwitchAction> {
177
- return (await requireSettings()).onSwitch;
178
- }
179
-
180
- export function normalizeOnSwitch(value: unknown): OnSwitchAction {
181
- if (value === "ask-before-pull" || value === "pull-after-switch" || value === "switch-only") {
182
- return value;
183
- }
184
- throw new Error(
185
- 'Invalid pi-sync settings: onSwitch must be "ask-before-pull", "pull-after-switch", or "switch-only".',
186
- );
187
- }
188
-
189
- function resolveSyncConfig(
190
- setupName: string,
191
- setup: SyncSetupSettings,
192
- connectionName: string,
193
- connection: StorageConnectionSettings,
194
- onSwitch: OnSwitchAction,
195
- skipSecretScan: boolean,
196
- ): AnySyncConfig {
197
- const storagePath = normalizeStoragePath(setup.storage.path);
198
- const namespace = storagePath.slice(storagePath.lastIndexOf("/") + 1);
199
- const include = normalizeSyncInclude(setup.sync.include);
200
- const common = {
201
- setupName,
202
- connectionName,
203
- storagePath,
204
- snapshotIdentity: namespace,
205
- include,
206
- automatic: setup.sync.automatic,
207
- onSwitch,
208
- skipSecretScan,
209
- };
210
- if (connection.type === "git") {
211
- return {
212
- ...common,
213
- backend: {
214
- type: "git",
215
- profile: { kind: "git", remote: normalizeGitRemote(connection.remote) as string },
216
- destination: {
217
- branch: normalizeGitBranch(setup.storage.branch),
218
- directory: normalizeGitDirectory(storagePath),
219
- namespace,
220
- },
221
- },
222
- };
223
- }
224
- if (connection.type === "webdav") {
225
- return {
226
- ...common,
227
- backend: {
228
- type: "webdav",
229
- profile: {
230
- kind: "webdav",
231
- url: normalizeWebDavUrl(connection.url) as string,
232
- username: connection.credentials.username,
233
- password: connection.credentials.password,
234
- },
235
- destination: { path: normalizeWebDavPath(storagePath), namespace },
236
- },
237
- };
238
- }
239
- return {
240
- ...common,
241
- backend: {
242
- type: "s3",
243
- profile: {
244
- kind: isCloudflareR2Endpoint(connection.endpoint) ? "r2" : "s3-compatible",
245
- endpoint: normalizeS3Endpoint(connection.endpoint),
246
- region: requiredString(connection.region, "S3 region"),
247
- accessKeyId: connection.credentials.accessKeyId,
248
- secretAccessKey: connection.credentials.secretAccessKey,
249
- sessionToken: optionalString(connection.credentials.sessionToken, "S3 session token"),
250
- },
251
- destination: {
252
- bucket: normalizeS3Bucket(setup.storage.bucket),
253
- prefix: storagePath,
254
- namespace,
255
- },
256
- },
257
- };
258
- }
259
-
260
- async function requireSettings() {
261
- const settings = await readLocalConfigObject();
262
- if (!settings) {
263
- throw new Error(`Missing pi-sync settings. Use /sync setup or create ${localConfigPath()}.`);
264
- }
265
- return settings;
266
- }
267
-
268
- export function validateSettingsDocument(value: Record<string, unknown>): PiSyncSettingsV3 {
269
- if (value.version !== 3) {
270
- throw new Error(
271
- `Unsupported pi-sync settings: version 3 is required. Keep the existing file for recovery, then create a new version 3 ${path.basename(localConfigPath())}; pi-sync will not migrate or overwrite old settings.`,
272
- );
273
- }
274
- rejectLegacyFields(
275
- value,
276
- [
277
- "profiles",
278
- "targets",
279
- "activeTarget",
280
- "targetSwitchAction",
281
- "endpoint",
282
- "bucket",
283
- "region",
284
- "accessKeyId",
285
- "secretAccessKey",
286
- "sessionToken",
287
- "profile",
288
- "prefix",
289
- "autoSync",
290
- "syncFiles",
291
- "syncSessions",
292
- "extraFiles",
293
- ],
294
- "top level",
295
- );
296
- normalizeOnSwitch(value.onSwitch);
297
- if (value.skipSecretScan !== undefined && typeof value.skipSecretScan !== "boolean") {
298
- throw new Error("Invalid pi-sync settings: skipSecretScan must be boolean.");
299
- }
300
- const storageConnections = requireNamedObjectMap(
301
- value.storageConnections,
302
- "storageConnections",
303
- "storage connection",
304
- );
305
- const syncSetups = requireNamedObjectMap(value.syncSetups, "syncSetups", "sync setup");
306
- for (const name of Object.keys(storageConnections)) {
307
- validateStorageConnection(
308
- name,
309
- requireOwnObject(storageConnections, name, "storage connection") as Record<string, unknown>,
310
- );
311
- }
312
- for (const name of Object.keys(syncSetups)) {
313
- validateSyncSetup(
314
- name,
315
- requireOwnObject(syncSetups, name, "sync setup") as Record<string, unknown>,
316
- storageConnections,
317
- );
318
- }
319
- const names = Object.keys(syncSetups);
320
- const activeSyncSetup = optionalCanonicalReference(value.activeSyncSetup, "activeSyncSetup");
321
- if (names.length === 0) {
322
- if (activeSyncSetup !== undefined) {
323
- throw new Error("Invalid pi-sync settings: empty syncSetups cannot have activeSyncSetup.");
324
- }
325
- } else if (!activeSyncSetup || !Object.hasOwn(syncSetups, activeSyncSetup)) {
326
- throw new Error(
327
- "Invalid pi-sync settings: activeSyncSetup must reference an existing own-property sync setup.",
328
- );
329
- }
330
- validateUniqueRemoteSyncSetups(syncSetups, storageConnections);
331
- return value as PiSyncSettingsV3;
332
- }
333
-
334
- function validateStorageConnection(name: string, value: Record<string, unknown>) {
335
- rejectLegacyFields(
336
- value,
337
- ["kind", "accessKeyId", "secretAccessKey", "sessionToken", "username", "password"],
338
- `storage connection “${name}”`,
339
- );
340
- const type = requiredString(value.type, `storage connection “${name}” type`);
341
- if (type !== "s3" && type !== "git" && type !== "webdav") {
342
- throw new Error(`Invalid pi-sync settings: storage connection “${name}” has unsupported type.`);
343
- }
344
- const known = ["endpoint", "region", "remote", "url", "credentials"];
345
- const allowed =
346
- type === "s3"
347
- ? new Set(["endpoint", "region", "credentials"])
348
- : type === "git"
349
- ? new Set(["remote"])
350
- : new Set(["url", "credentials"]);
351
- if (known.some((field) => Object.hasOwn(value, field) && !allowed.has(field))) {
352
- throw new Error(
353
- `Invalid pi-sync settings: ${type.toUpperCase()} storage connection “${name}” mixes backend fields.`,
354
- );
355
- }
356
- if (type === "git") {
357
- if (!normalizeGitRemote(requiredString(value.remote, `Git remote for “${name}”`))) {
358
- throw new Error(`Invalid pi-sync settings: Git remote for “${name}” is required.`);
359
- }
360
- return;
361
- }
362
- const credentials = requireRecord(
363
- value.credentials,
364
- `credentials for storage connection “${name}”`,
365
- );
366
- if (type === "webdav") {
367
- normalizeWebDavUrl(requiredString(value.url, `WebDAV URL for “${name}”`));
368
- const username = requiredString(credentials.username, `WebDAV username for “${name}”`);
369
- const password = requiredSecret(credentials.password, `WebDAV password for “${name}”`);
370
- if (
371
- ["accessKeyId", "secretAccessKey", "sessionToken"].some((field) =>
372
- Object.hasOwn(credentials, field),
373
- )
374
- ) {
375
- throw new Error(`Invalid pi-sync settings: WebDAV credentials for “${name}” mix fields.`);
376
- }
377
- validateWebDavCredentials(username, password);
378
- return;
379
- }
380
- normalizeS3Endpoint(requiredString(value.endpoint, `S3 endpoint for “${name}”`));
381
- requiredString(value.region, `S3 region for “${name}”`);
382
- requiredString(credentials.accessKeyId, `S3 access key id for “${name}”`);
383
- requiredSecret(credentials.secretAccessKey, `S3 secret access key for “${name}”`);
384
- optionalString(credentials.sessionToken, `S3 session token for “${name}”`);
385
- if (["username", "password"].some((field) => Object.hasOwn(credentials, field))) {
386
- throw new Error(`Invalid pi-sync settings: S3 credentials for “${name}” mix fields.`);
387
- }
388
- }
389
-
390
- function validateSyncSetup(
391
- name: string,
392
- value: Record<string, unknown>,
393
- connections: Record<string, unknown>,
394
- ) {
395
- rejectLegacyFields(
396
- value,
397
- [
398
- "profile",
399
- "bucket",
400
- "branch",
401
- "path",
402
- "prefix",
403
- "directory",
404
- "namespace",
405
- "autoSync",
406
- "syncFiles",
407
- "syncSessions",
408
- "extraFiles",
409
- ],
410
- `sync setup “${name}”`,
411
- );
412
- const storage = requireRecord(value.storage, `storage for sync setup “${name}”`);
413
- const sync = requireRecord(value.sync, `sync policy for sync setup “${name}”`);
414
- rejectLegacyFields(
415
- storage,
416
- ["profile", "prefix", "directory", "namespace"],
417
- `storage for sync setup “${name}”`,
418
- );
419
- rejectLegacyFields(
420
- sync,
421
- ["autoSync", "syncFiles", "syncSessions", "extraFiles"],
422
- `sync policy for sync setup “${name}”`,
423
- );
424
- const connectionName = requiredCanonicalReference(
425
- storage.connection,
426
- `storage connection reference for sync setup “${name}”`,
427
- );
428
- validateConfigName(connectionName, "storage connection reference");
429
- const connection = ownObject<Record<string, unknown>>(connections, connectionName);
430
- if (!connection) {
431
- throw new Error(
432
- `Invalid pi-sync settings: sync setup “${name}” references missing storage connection “${connectionName}”.`,
433
- );
434
- }
435
- const type = connection.type;
436
- normalizeStoragePath(requiredString(storage.path, `storage path for sync setup “${name}”`));
437
- if (type === "s3") {
438
- normalizeS3Bucket(requiredString(storage.bucket, `S3 bucket for sync setup “${name}”`));
439
- if (Object.hasOwn(storage, "branch")) mixedSetupError("S3", name);
440
- } else if (type === "git") {
441
- normalizeGitBranch(requiredString(storage.branch, `Git branch for sync setup “${name}”`));
442
- if (Object.hasOwn(storage, "bucket")) mixedSetupError("Git", name);
443
- } else if (type === "webdav") {
444
- if (Object.hasOwn(storage, "bucket") || Object.hasOwn(storage, "branch")) {
445
- mixedSetupError("WebDAV", name);
446
- }
447
- }
448
- if (!Object.hasOwn(sync, "include")) {
449
- throw new Error(`Invalid pi-sync settings: sync setup “${name}” is missing sync.include.`);
450
- }
451
- normalizeSyncInclude(sync.include);
452
- if (typeof sync.automatic !== "boolean") {
453
- throw new Error(
454
- `Invalid pi-sync settings: sync setup “${name}” sync.automatic must be boolean.`,
455
- );
456
- }
457
- }
458
-
459
- function mixedSetupError(type: string, name: string): never {
460
- throw new Error(`Invalid pi-sync settings: ${type} sync setup “${name}” mixes backend fields.`);
461
- }
462
-
463
- export function validateUniqueRemoteSyncSetups(
464
- setups: Record<string, unknown>,
465
- connections: Record<string, unknown>,
466
- ) {
467
- const identities = new Map<string, string>();
468
- for (const name of Object.keys(setups)) {
469
- const setup = requireOwnObject(setups, name, "sync setup") as unknown as SyncSetupSettings;
470
- const connection = requireOwnObject(
471
- connections,
472
- setup.storage.connection,
473
- "storage connection",
474
- ) as unknown as StorageConnectionSettings;
475
- const identity = effectiveSyncSetupRemoteIdentity(setup, connection);
476
- const existing = identities.get(identity);
477
- if (existing) {
478
- throw new Error(
479
- `Invalid pi-sync settings: sync setups “${existing}” and “${name}” use the same normalized remote location.`,
480
- );
481
- }
482
- identities.set(identity, name);
483
- }
484
- }
485
-
486
- export function effectiveSyncSetupRemoteIdentity(
487
- setup: SyncSetupSettings,
488
- connection: StorageConnectionSettings,
489
- ) {
490
- const storagePath = normalizeStoragePath(setup.storage.path);
491
- if (connection.type === "git") {
492
- return JSON.stringify([
493
- "git",
494
- normalizeGitRemoteIdentity(connection.remote),
495
- normalizeGitBranch(setup.storage.branch),
496
- normalizeGitDirectory(storagePath),
497
- ]);
498
- }
499
- if (connection.type === "webdav") {
500
- return JSON.stringify([
501
- "webdav",
502
- normalizeWebDavIdentityUrl(connection.url),
503
- connection.credentials.username.trim(),
504
- normalizeWebDavPath(storagePath),
505
- ]);
506
- }
507
- return JSON.stringify([
508
- "s3",
509
- normalizeEndpointIdentity(connection.endpoint),
510
- normalizeS3Bucket(setup.storage.bucket),
511
- storagePath,
512
- ]);
513
- }
514
-
515
- function rejectLegacyFields(
516
- value: Record<string, unknown>,
517
- fields: readonly string[],
518
- context: string,
519
- ) {
520
- const field = fields.find((candidate) => Object.hasOwn(value, candidate));
521
- if (field) {
522
- throw new Error(
523
- `Invalid pi-sync settings: ${context} contains unsupported version 1/2 field “${field}”.`,
524
- );
525
- }
526
- }
527
-
528
- function requireNamedObjectMap(value: unknown, field: string, itemLabel: string) {
529
- const result = requireRecord(value, field);
530
- for (const name of Object.keys(result)) validateConfigName(name, itemLabel);
531
- return result;
532
- }
533
-
534
- function requireOwnObject(value: Record<string, unknown>, key: string, label: string) {
535
- const item = ownObject(value, key);
536
- if (!item) throw new Error(`Invalid pi-sync settings: ${label} “${key}” must be an object.`);
537
- return item;
538
- }
539
-
540
- function ownObject<T extends object>(value: Record<string, unknown>, key: string): T | undefined {
541
- if (!Object.hasOwn(value, key)) return undefined;
542
- const item = value[key];
543
- return item && typeof item === "object" && !Array.isArray(item) ? (item as T) : undefined;
544
- }
545
-
546
- function requireRecord(value: unknown, field: string): Record<string, unknown> {
547
- if (!value || typeof value !== "object" || Array.isArray(value)) {
548
- throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
549
- }
550
- return value as Record<string, unknown>;
551
- }
552
-
553
- export function validateConfigName(value: string, field: string) {
554
- if (
555
- !value.trim() ||
556
- value !== value.trim() ||
557
- value.length > 100 ||
558
- value === "__proto__" ||
559
- value === "prototype" ||
560
- value === "constructor" ||
561
- hasControlCharacter(value)
562
- ) {
563
- throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
564
- }
565
- }
566
-
567
- function requiredString(value: unknown, field: string) {
568
- if (typeof value !== "string" || !value.trim() || hasControlCharacter(value)) {
569
- throw new Error(`Invalid pi-sync settings: ${field} must be a non-empty string.`);
570
- }
571
- return value.trim();
572
- }
573
-
574
- function requiredCanonicalReference(value: unknown, field: string) {
575
- const normalized = requiredString(value, field);
576
- if (value !== normalized) {
577
- throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
578
- }
579
- return normalized;
580
- }
581
-
582
- function optionalCanonicalReference(value: unknown, field: string) {
583
- const normalized = optionalString(value, field);
584
- if (normalized !== undefined && value !== normalized) {
585
- throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
586
- }
587
- return normalized;
588
- }
589
-
590
- function requiredSecret(value: unknown, field: string) {
591
- if (typeof value !== "string" || !value || hasControlCharacter(value)) {
592
- throw new Error(`Invalid pi-sync settings: ${field} must be configured.`);
593
- }
594
- return value;
595
- }
596
-
597
- function optionalString(value: unknown, field: string) {
598
- if (value === undefined) return undefined;
599
- if (typeof value !== "string" || hasControlCharacter(value)) {
600
- throw new Error(`Invalid pi-sync settings: ${field} must be a string.`);
601
- }
602
- return value.trim() || undefined;
603
- }
604
-
605
- export function normalizeStoragePath(value: string) {
606
- const normalized = value.trim().replace(/^\/+|\/+$/gu, "");
607
- if (
608
- !normalized ||
609
- normalized.length > 1024 ||
610
- normalized.startsWith("-") ||
611
- normalized.includes("\\") ||
612
- hasControlCharacter(normalized) ||
613
- normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
614
- ) {
615
- throw new Error("Invalid pi-sync settings: storage.path must be a safe relative path.");
616
- }
617
- return normalized;
618
- }
619
-
620
- function normalizeS3Endpoint(value: string) {
621
- let url: URL;
622
- try {
623
- url = new URL(value.trim());
624
- } catch {
625
- throw new Error("Invalid pi-sync S3 endpoint.");
626
- }
627
- const loopback =
628
- url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
629
- if (
630
- (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
631
- url.username ||
632
- url.password ||
633
- url.search ||
634
- url.hash
635
- ) {
636
- throw new Error("Invalid pi-sync S3 endpoint: HTTPS is required except for loopback.");
637
- }
638
- url.pathname = url.pathname.replace(/\/+$/gu, "");
639
- return url.toString().replace(/\/$/u, "");
640
- }
641
-
642
- function normalizeS3Bucket(value: string | undefined) {
643
- const bucket = requiredString(value, "S3 bucket");
644
- if (bucket.includes("/") || bucket.includes("\\") || bucket.startsWith("-")) {
645
- throw new Error("Invalid pi-sync S3 bucket.");
646
- }
647
- return bucket;
648
- }
649
-
650
- function normalizeEndpointIdentity(endpoint: string) {
651
- try {
652
- const url = new URL(endpoint.trim());
653
- url.hostname = url.hostname.toLowerCase();
654
- url.pathname = url.pathname.replace(/\/+$/gu, "");
655
- return url.toString().replace(/\/$/u, "");
656
- } catch {
657
- return endpoint.trim();
658
- }
659
- }
660
-
661
- export async function configuredSessionDir() {
662
- const settings = await readJsonIfExists<{ sessionDir?: string }>(
663
- path.join(agentDir(), "settings.json"),
664
- );
665
- return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
666
- }
667
-
668
- export async function sessionDirForApply(
669
- ctx: ExtensionCommandContext | ExtensionContext,
670
- snapshot: Snapshot,
671
- ) {
672
- const contextSessionDir = sessionDirFromContext(ctx);
673
- const localSessionDir = await configuredSessionDir();
674
- if (
675
- contextSessionDir &&
676
- path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")
677
- ) {
678
- return contextSessionDir;
679
- }
680
- return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
681
- }
682
-
683
- function sessionDirFromSnapshot(snapshot: Snapshot) {
684
- const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
685
- if (!settingsFile) return undefined;
686
- try {
687
- const settings = JSON.parse(
688
- decodeBase64Strict(settingsFile.contentBase64, settingsFile.path).toString("utf8"),
689
- ) as { sessionDir?: string };
690
- return settings.sessionDir ? expandHome(settings.sessionDir) : undefined;
691
- } catch {
692
- return undefined;
693
- }
694
- }
695
-
696
- function decodeBase64Strict(value: string, filePath: string) {
697
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
698
- throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
699
- }
700
- return Buffer.from(value, "base64");
701
- }
702
-
703
- export async function readState(profile: string): Promise<SyncState> {
704
- return (
705
- (await readJsonIfExists<SyncState>(statePath(profile))) ?? {
706
- version: STATE_VERSION,
707
- profile,
708
- lastFileHashes: {},
709
- }
710
- );
711
- }
712
-
713
- export async function writeState(profile: string, state: SyncState) {
714
- await writeJson(statePath(profile), state);
715
- }
716
-
717
- export async function readStateForConfig(config: AnySyncConfig): Promise<SyncState> {
718
- return (
719
- (await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
720
- version: STATE_VERSION,
721
- profile: config.snapshotIdentity,
722
- lastFileHashes: {},
723
- }
724
- );
725
- }
726
-
727
- export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
728
- await writeJson(statePathForConfig(config), state);
729
- }
730
-
731
- export function statePathForConfig(config: AnySyncConfig) {
732
- const identity = backendIdentityCoordinates(config);
733
- const hash = createHash("sha256").update(identity).digest("hex").slice(0, 16);
734
- return path.join(stateDir(), "setups", `${config.backend.type}-${hash}.state.json`);
735
- }
736
-
737
- function backendIdentityCoordinates(config: AnySyncConfig) {
738
- switch (config.backend.type) {
739
- case "s3":
740
- return JSON.stringify([
741
- "s3",
742
- normalizeEndpointIdentity(config.backend.profile.endpoint),
743
- config.backend.destination.bucket,
744
- config.storagePath,
745
- ]);
746
- case "git":
747
- return JSON.stringify([
748
- "git",
749
- normalizeGitRemoteIdentity(config.backend.profile.remote),
750
- config.backend.destination.branch,
751
- config.storagePath,
752
- ]);
753
- case "webdav":
754
- return JSON.stringify([
755
- "webdav",
756
- normalizeWebDavIdentityUrl(config.backend.profile.url),
757
- config.backend.profile.username,
758
- config.storagePath,
759
- ]);
760
- }
761
- }
762
-
763
- export function agentDir() {
764
- return getAgentDir();
765
- }
766
-
767
- function expandHome(value: string) {
768
- return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
769
- }
770
-
771
- export { stateDir } from "./state-directory.js";
772
-
773
- export function localConfigTemplate(): PiSyncSettingsV3 {
774
- return {
775
- version: 3,
776
- onSwitch: DEFAULT_ON_SWITCH,
777
- skipSecretScan: false,
778
- storageConnections: {},
779
- syncSetups: {},
780
- };
781
- }
782
-
783
- export async function readLocalConfigDocument() {
784
- const document = await readMigratingLocalConfigDocument((settings) => {
785
- validateSettingsDocument(settings);
786
- });
787
- if (document) validateSettingsDocument(document.parsed);
788
- return document;
789
- }
790
-
791
- export async function readLocalConfigObject(): Promise<PiSyncSettingsV3 | undefined> {
792
- return (await readLocalConfigDocument())?.parsed as PiSyncSettingsV3 | undefined;
793
- }
794
-
795
- let configUpdateQueue: Promise<void> = Promise.resolve();
796
-
797
- export function updateLocalConfig(
798
- update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
799
- signal?: AbortSignal,
800
- ) {
801
- const operation = configUpdateQueue.then(() => {
802
- signal?.throwIfAborted();
803
- return performLocalConfigUpdate(update, signal);
804
- });
805
- configUpdateQueue = operation.then(
806
- () => undefined,
807
- () => undefined,
808
- );
809
- return operation;
810
- }
811
-
812
- async function performLocalConfigUpdate(
813
- update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
814
- signal?: AbortSignal,
815
- ) {
816
- return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
817
- }
818
-
819
- export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
820
- validateSettingsDocument(value as Record<string, unknown>);
821
- const configPath = localConfigPath();
822
- await fs.mkdir(path.dirname(configPath), { recursive: true });
823
- try {
824
- const stat = await fs.lstat(configPath);
825
- if (stat.isSymbolicLink())
826
- throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
827
- if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
828
- } catch (error) {
829
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
830
- }
831
- const temporaryPath = path.join(
832
- path.dirname(configPath),
833
- `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
834
- );
835
- let handle: fs.FileHandle | undefined;
836
- try {
837
- handle = await fs.open(temporaryPath, "wx", 0o600);
838
- await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
839
- if (process.platform !== "win32") await handle.chmod(0o600);
840
- await handle.sync();
841
- await handle.close();
842
- handle = undefined;
843
- await fs.rename(temporaryPath, configPath);
844
- } catch (error) {
845
- await handle?.close().catch(() => undefined);
846
- await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
847
- throw error;
848
- }
849
- }
850
-
851
- function statePath(profile: string) {
852
- return path.join(stateDir(), `${safeName(profile)}.state.json`);
853
- }
854
-
855
- export function lockPath() {
856
- return path.join(stateDir(), "lock");
857
- }
858
-
859
- export async function ensureStateDir() {
860
- await fs.mkdir(stateDir(), { recursive: true });
861
- }
862
-
863
- async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
864
- try {
865
- return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
866
- } catch (error) {
867
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
868
- throw error;
869
- }
870
- }
871
-
872
- export async function writeJson(filePath: string, value: unknown) {
873
- await fs.mkdir(path.dirname(filePath), { recursive: true });
874
- const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
875
- await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
876
- if (process.platform !== "win32") await fs.chmod(temp, 0o600);
877
- await fs.rename(temp, filePath);
878
- }
879
-
880
- export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
881
- if (!isCloudflareR2Endpoint(config.endpoint) || !config.sessionToken) return [];
882
- return [
883
- "session token: configured for Cloudflare R2; if R2 rejects X-Amz-Security-Token, pi-sync retries once without it. R2 static access keys usually do not need a session token.",
884
- ];
885
- }
886
-
887
- export function syncSessionsWarnings(config: { include: readonly string[] }) {
888
- if (!config.include.includes("sessions")) return [];
889
- return [
890
- "sessions: included; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
891
- ];
892
- }
893
-
894
- export function isCloudflareR2Endpoint(endpoint: string | undefined) {
895
- const value = endpoint?.trim();
896
- if (!value) return false;
897
- try {
898
- const hostname = new URL(value).hostname.toLowerCase();
899
- return (
900
- hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
901
- );
902
- } catch {
903
- return false;
904
- }
905
- }
906
-
907
- export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
908
- if (value === undefined) return defaultValue;
909
- if (typeof value === "boolean") return value;
910
- return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
911
- }
912
-
913
- export function isExplicitlyEnabled(value: boolean | string | undefined) {
914
- if (typeof value === "boolean") return value;
915
- return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
916
- }
917
-
918
- function hasControlCharacter(value: string) {
919
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Stored settings cannot contain controls.
920
- return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
921
- }
922
-
923
- export { isMissingConfigError } from "./config-errors.js";