@narumitw/pi-sync 0.31.0 → 0.35.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.
package/src/config.ts CHANGED
@@ -10,350 +10,638 @@ import {
10
10
  import {
11
11
  activeLocalConfigPath,
12
12
  consumeLocalConfigMigrationNotice,
13
+ createLocalConfigDocument,
13
14
  legacyLocalConfigPath,
14
15
  localConfigPath,
15
16
  quarantineAndRemoveConfigIfMatches,
17
+ readActiveLocalConfigDocumentForRepair,
16
18
  readMigratingLocalConfigDocument,
17
19
  replaceLocalConfigDocument,
18
- withLocalConfigFileLock,
20
+ updateLocalConfigDocument,
19
21
  } from "./config-file.js";
22
+ import {
23
+ normalizeGitBranch,
24
+ normalizeGitDirectory,
25
+ normalizeGitRemote,
26
+ normalizeGitRemoteIdentity,
27
+ } from "./git-config.js";
20
28
  import { safeName } from "./paths.js";
21
- import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
29
+ import { normalizeSyncInclude } from "./sync-policy.js";
22
30
  import type {
31
+ AnySyncConfig,
32
+ OnSwitchAction,
23
33
  PartialConfig,
34
+ PiSyncSettingsV3,
24
35
  Snapshot,
25
- SyncConfig,
36
+ StorageConnectionSettings,
37
+ SyncSetupSettings,
26
38
  SyncState,
27
- TargetSwitchAction,
28
39
  } from "./types.js";
40
+ import {
41
+ normalizeWebDavIdentityUrl,
42
+ normalizeWebDavPath,
43
+ normalizeWebDavUrl,
44
+ validateWebDavCredentials,
45
+ } from "./webdav-config.js";
29
46
 
30
- export { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
31
-
32
- const VERSION = 1;
33
- const DEFAULT_PROFILE = "default";
34
- const DEFAULT_PREFIX = "pi-sync";
35
- const DEFAULT_REGION = "auto";
36
- export const DEFAULT_TARGET_SWITCH_ACTION: TargetSwitchAction = "ask";
47
+ const STATE_VERSION = 2;
48
+ export const DEFAULT_ON_SWITCH: OnSwitchAction = "ask-before-pull";
37
49
 
50
+ export { normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
38
51
  export {
39
52
  activeLocalConfigPath,
40
53
  consumeLocalConfigMigrationNotice,
54
+ createLocalConfigDocument,
41
55
  legacyLocalConfigPath,
42
56
  localConfigPath,
57
+ normalizeSyncInclude,
43
58
  quarantineAndRemoveConfigIfMatches,
59
+ readActiveLocalConfigDocumentForRepair,
44
60
  replaceLocalConfigDocument,
61
+ updateLocalConfigDocument,
45
62
  };
46
63
 
47
- export const DEPRECATED_PI_SYNC_ENV_NAMES = [
48
- "PI_SYNC_ENDPOINT",
49
- "PI_SYNC_BUCKET",
50
- "PI_SYNC_REGION",
51
- "PI_SYNC_ACCESS_KEY_ID",
52
- "PI_SYNC_SECRET_ACCESS_KEY",
53
- "PI_SYNC_SESSION_TOKEN",
54
- "PI_SYNC_PROFILE",
55
- "PI_SYNC_PREFIX",
56
- "PI_SYNC_AUTO_SYNC",
57
- "PI_SYNC_SESSIONS",
58
- ] as const;
59
-
60
- function trimSlashes(value: string) {
61
- return value.replace(/^\/+|\/+$/g, "");
62
- }
63
-
64
- function decodeBase64Strict(value: string, filePath: string) {
65
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
66
- throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
67
- }
68
- return Buffer.from(value, "base64");
69
- }
70
-
71
64
  function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
72
65
  const manager = ctx.sessionManager as typeof ctx.sessionManager & {
73
66
  usesDefaultSessionDir?: () => boolean;
74
67
  };
75
- const usesDefaultSessionDir = manager.usesDefaultSessionDir;
76
- if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
77
- return undefined;
78
- }
79
- const getSessionDir = manager.getSessionDir;
80
- return typeof getSessionDir === "function"
81
- ? (getSessionDir.call(manager) as string | undefined)
68
+ if (manager.usesDefaultSessionDir?.call(manager)) return undefined;
69
+ return typeof manager.getSessionDir === "function"
70
+ ? (manager.getSessionDir.call(manager) as string | undefined)
82
71
  : undefined;
83
72
  }
84
73
 
85
- export async function loadConfigInternal(targetName?: string): Promise<SyncConfig> {
86
- const partial = await loadPartialConfig(targetName);
87
- const endpoint = normalizeConfiguredString(partial.endpoint);
88
- const bucket = normalizeConfiguredString(partial.bucket);
89
- const accessKeyId = normalizeConfiguredString(partial.accessKeyId);
90
- const secretAccessKey = normalizeConfiguredString(partial.secretAccessKey);
91
- const missing = [
92
- ["endpoint", endpoint],
93
- ["bucket", bucket],
94
- ["accessKeyId", accessKeyId],
95
- ["secretAccessKey", secretAccessKey],
96
- ]
97
- .filter(([, value]) => !value)
98
- .map(([name]) => name);
99
- if (missing.length > 0) {
74
+ export async function loadConfig(setupName?: string): Promise<AnySyncConfig> {
75
+ const settings = await requireSettings();
76
+ const selectedName = setupName ?? settings.activeSyncSetup;
77
+ if (!selectedName) throw new Error("No sync setups are configured.");
78
+ validateConfigName(selectedName, "sync setup");
79
+ const setup = ownObject<SyncSetupSettings>(settings.syncSetups, selectedName);
80
+ if (!setup)
81
+ throw new Error(`Invalid pi-sync settings: sync setup “${selectedName}” was not found.`);
82
+ const connectionName = setup.storage.connection;
83
+ const connection = ownObject<StorageConnectionSettings>(
84
+ settings.storageConnections,
85
+ connectionName,
86
+ );
87
+ if (!connection) {
100
88
  throw new Error(
101
- `Missing pi-sync config: ${missing.join(", ")}. Use /sync setup or edit ${localConfigPath()}.`,
89
+ `Invalid pi-sync settings: sync setup “${selectedName}” references missing storage connection “${connectionName}”.`,
102
90
  );
103
91
  }
104
- if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
105
- throw new Error("Missing pi-sync config after validation.");
106
- }
92
+ return resolveSyncConfig(selectedName, setup, connectionName, connection, settings.onSwitch);
93
+ }
94
+
95
+ export type SyncSetupStorageReview = Pick<
96
+ PartialConfig,
97
+ "connectionName" | "storageKind" | "storagePath" | "bucket" | "branch"
98
+ >;
107
99
 
100
+ /** A validated setup-facing projection used by manager and settings UI. */
101
+ export async function loadPartialConfig(setupName?: string): Promise<PartialConfig> {
102
+ const config = await loadConfig(setupName);
108
103
  return {
109
- endpoint,
110
- bucket,
111
- region: normalizeOptionalString(partial.region) ?? DEFAULT_REGION,
112
- accessKeyId,
113
- secretAccessKey,
114
- sessionToken: normalizeOptionalString(partial.sessionToken),
115
- profile: normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE,
116
- prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
117
- target: partial.target ?? DEFAULT_PROFILE,
118
- storageProfile: partial.storageProfile ?? DEFAULT_PROFILE,
119
- storageKind: partial.storageKind,
120
- autoSync: isEnabled(partial.autoSync, true),
121
- settingsVersion: partial.settingsVersion ?? 1,
122
- syncFiles: normalizeSyncFiles(partial.syncFiles),
123
- syncSessions: isExplicitlyEnabled(partial.syncSessions),
124
- extraFiles: normalizeExtraFiles(partial.extraFiles),
104
+ setupName: config.setupName,
105
+ ...storageReviewFromConfig(config),
106
+ include: [...config.include],
107
+ automatic: config.automatic,
108
+ onSwitch: config.onSwitch,
125
109
  };
126
110
  }
127
111
 
128
- export async function loadConfig(targetName?: string): Promise<SyncConfig> {
129
- return loadConfigInternal(targetName);
112
+ export function syncSetupStorageReview(
113
+ setupName: string,
114
+ setup: SyncSetupSettings,
115
+ connectionName: string,
116
+ connection: StorageConnectionSettings,
117
+ ): SyncSetupStorageReview {
118
+ return storageReviewFromConfig(
119
+ resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH),
120
+ );
130
121
  }
131
122
 
132
- export async function configuredTargetNames() {
133
- const settings = await readLocalConfigObject();
134
- if (!settings) return [];
135
- if (!isV2SettingsObject(settings)) return [];
136
- return Object.keys(requireNamedObjectMap(settings.targets, "targets")).sort((left, right) =>
137
- left.localeCompare(right),
123
+ export function syncSetupReviewIdentity(
124
+ setupName: string,
125
+ setup: SyncSetupSettings,
126
+ connectionName: string,
127
+ connection: StorageConnectionSettings,
128
+ ) {
129
+ return syncConfigReviewIdentity(
130
+ resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH),
138
131
  );
139
132
  }
140
133
 
141
- export async function loadPartialConfig(targetName?: string): Promise<PartialConfig> {
142
- const fileConfig = (await readLocalConfigObject()) ?? {};
143
- if (isV2SettingsObject(fileConfig)) return resolveV2PartialConfig(fileConfig, targetName);
144
- if (targetName !== undefined) {
145
- throw new Error("--target requires version 2 pi-sync settings with named targets.");
146
- }
147
- return resolveLegacyPartialConfig(fileConfig as PartialConfig);
134
+ export function syncConfigReviewIdentity(config: AnySyncConfig) {
135
+ return JSON.stringify([
136
+ config.setupName,
137
+ config.connectionName,
138
+ backendIdentityCoordinates(config),
139
+ config.include,
140
+ config.automatic,
141
+ ]);
148
142
  }
149
143
 
150
- export async function loadTargetSwitchAction(): Promise<TargetSwitchAction> {
151
- const settings = await readLocalConfigObject();
152
- if (!settings || !isV2SettingsObject(settings)) return DEFAULT_TARGET_SWITCH_ACTION;
153
- return normalizeTargetSwitchAction(settings.targetSwitchAction);
144
+ function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
145
+ return {
146
+ connectionName: config.connectionName,
147
+ storageKind: config.backend.type,
148
+ storagePath: config.storagePath,
149
+ ...(config.backend.type === "s3"
150
+ ? { bucket: config.backend.destination.bucket }
151
+ : config.backend.type === "git"
152
+ ? { branch: config.backend.destination.branch }
153
+ : {}),
154
+ };
154
155
  }
155
156
 
156
- export function normalizeTargetSwitchAction(value: unknown): TargetSwitchAction {
157
- if (value === undefined) return DEFAULT_TARGET_SWITCH_ACTION;
158
- if (value === "ask" || value === "pull" || value === "switch-only") return value;
159
- throw new Error(
160
- 'Invalid pi-sync settings: targetSwitchAction must be "ask", "pull", or "switch-only".',
161
- );
157
+ export async function configuredSyncSetupNames() {
158
+ const settings = await readLocalConfigObject();
159
+ return settings
160
+ ? Object.keys(settings.syncSetups).sort((left, right) => left.localeCompare(right))
161
+ : [];
162
162
  }
163
163
 
164
- export function deprecatedPiSyncEnvironmentNames() {
165
- return DEPRECATED_PI_SYNC_ENV_NAMES.filter((name) => hasEnv(name));
164
+ export async function loadOnSwitch(): Promise<OnSwitchAction> {
165
+ return (await requireSettings()).onSwitch;
166
166
  }
167
167
 
168
- export function deprecatedPiSyncEnvironmentWarnings() {
169
- const names = deprecatedPiSyncEnvironmentNames();
170
- if (names.length === 0) return [];
171
- return [
172
- `deprecated environment: ${names.join(", ")} still override pi-sync settings but will be removed in a future major version. Move them to ${localConfigPath()}; values are not shown.`,
173
- ];
168
+ export function normalizeOnSwitch(value: unknown): OnSwitchAction {
169
+ if (value === "ask-before-pull" || value === "pull-after-switch" || value === "switch-only") {
170
+ return value;
171
+ }
172
+ throw new Error(
173
+ 'Invalid pi-sync settings: onSwitch must be "ask-before-pull", "pull-after-switch", or "switch-only".',
174
+ );
174
175
  }
175
176
 
176
- export function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
177
+ function resolveSyncConfig(
178
+ setupName: string,
179
+ setup: SyncSetupSettings,
180
+ connectionName: string,
181
+ connection: StorageConnectionSettings,
182
+ onSwitch: OnSwitchAction,
183
+ ): AnySyncConfig {
184
+ const storagePath = normalizeStoragePath(setup.storage.path);
185
+ const namespace = storagePath.slice(storagePath.lastIndexOf("/") + 1);
186
+ const include = normalizeSyncInclude(setup.sync.include);
187
+ const common = {
188
+ setupName,
189
+ connectionName,
190
+ storagePath,
191
+ snapshotIdentity: namespace,
192
+ include,
193
+ automatic: setup.sync.automatic,
194
+ onSwitch,
195
+ };
196
+ if (connection.type === "git") {
197
+ return {
198
+ ...common,
199
+ backend: {
200
+ type: "git",
201
+ profile: { kind: "git", remote: normalizeGitRemote(connection.remote) as string },
202
+ destination: {
203
+ branch: normalizeGitBranch(setup.storage.branch),
204
+ directory: normalizeGitDirectory(storagePath),
205
+ namespace,
206
+ },
207
+ },
208
+ };
209
+ }
210
+ if (connection.type === "webdav") {
211
+ return {
212
+ ...common,
213
+ backend: {
214
+ type: "webdav",
215
+ profile: {
216
+ kind: "webdav",
217
+ url: normalizeWebDavUrl(connection.url) as string,
218
+ username: connection.credentials.username,
219
+ password: connection.credentials.password,
220
+ },
221
+ destination: { path: normalizeWebDavPath(storagePath), namespace },
222
+ },
223
+ };
224
+ }
177
225
  return {
178
- ...fileConfig,
179
- target: DEFAULT_PROFILE,
180
- storageProfile: DEFAULT_PROFILE,
181
- settingsVersion: 1,
182
- endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
183
- bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
184
- region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
185
- accessKeyId:
186
- process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
187
- secretAccessKey:
188
- process.env.PI_SYNC_SECRET_ACCESS_KEY ??
189
- process.env.AWS_SECRET_ACCESS_KEY ??
190
- fileConfig.secretAccessKey,
191
- sessionToken: selectSessionToken(fileConfig.sessionToken),
192
- profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
193
- prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
194
- autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
195
- syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
226
+ ...common,
227
+ backend: {
228
+ type: "s3",
229
+ profile: {
230
+ kind: isCloudflareR2Endpoint(connection.endpoint) ? "r2" : "s3-compatible",
231
+ endpoint: normalizeS3Endpoint(connection.endpoint),
232
+ region: requiredString(connection.region, "S3 region"),
233
+ accessKeyId: connection.credentials.accessKeyId,
234
+ secretAccessKey: connection.credentials.secretAccessKey,
235
+ sessionToken: optionalString(connection.credentials.sessionToken, "S3 session token"),
236
+ },
237
+ destination: {
238
+ bucket: normalizeS3Bucket(setup.storage.bucket),
239
+ prefix: storagePath,
240
+ namespace,
241
+ },
242
+ },
196
243
  };
197
244
  }
198
245
 
199
- export function resolveV2PartialConfig(
200
- settings: Record<string, unknown>,
201
- targetName?: string,
202
- ): PartialConfig {
203
- normalizeTargetSwitchAction(settings.targetSwitchAction);
204
- const targets = requireNamedObjectMap(settings.targets, "targets");
205
- const profiles = requireNamedObjectMap(settings.profiles, "profiles");
206
- validateUniqueRemoteTargets(targets);
207
- const selectedTarget =
208
- targetName ?? normalizeOptionalString(asOptionalString(settings.activeTarget));
209
- if (!selectedTarget) throw new Error("Invalid pi-sync settings: activeTarget is required.");
210
- validateConfigName(selectedTarget, "target");
211
- const target = ownObject(targets, selectedTarget);
212
- if (!target)
213
- throw new Error(`Invalid pi-sync settings: target "${selectedTarget}" was not found.`);
214
- const storageProfile = normalizeOptionalString(asOptionalString(target.profile));
215
- if (!storageProfile) {
216
- throw new Error(`Invalid pi-sync settings: target "${selectedTarget}" is missing profile.`);
217
- }
218
- validateConfigName(storageProfile, "storage profile");
219
- const profile = ownObject(profiles, storageProfile);
220
- if (!profile) {
246
+ async function requireSettings() {
247
+ const settings = await readLocalConfigObject();
248
+ if (!settings) {
249
+ throw new Error(`Missing pi-sync settings. Use /sync setup or create ${localConfigPath()}.`);
250
+ }
251
+ return settings;
252
+ }
253
+
254
+ export function validateSettingsDocument(value: Record<string, unknown>): PiSyncSettingsV3 {
255
+ if (value.version !== 3) {
256
+ throw new Error(
257
+ `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.`,
258
+ );
259
+ }
260
+ rejectLegacyFields(
261
+ value,
262
+ [
263
+ "profiles",
264
+ "targets",
265
+ "activeTarget",
266
+ "targetSwitchAction",
267
+ "endpoint",
268
+ "bucket",
269
+ "region",
270
+ "accessKeyId",
271
+ "secretAccessKey",
272
+ "sessionToken",
273
+ "profile",
274
+ "prefix",
275
+ "autoSync",
276
+ "syncFiles",
277
+ "syncSessions",
278
+ "extraFiles",
279
+ ],
280
+ "top level",
281
+ );
282
+ normalizeOnSwitch(value.onSwitch);
283
+ const storageConnections = requireNamedObjectMap(
284
+ value.storageConnections,
285
+ "storageConnections",
286
+ "storage connection",
287
+ );
288
+ const syncSetups = requireNamedObjectMap(value.syncSetups, "syncSetups", "sync setup");
289
+ for (const name of Object.keys(storageConnections)) {
290
+ validateStorageConnection(
291
+ name,
292
+ requireOwnObject(storageConnections, name, "storage connection") as Record<string, unknown>,
293
+ );
294
+ }
295
+ for (const name of Object.keys(syncSetups)) {
296
+ validateSyncSetup(
297
+ name,
298
+ requireOwnObject(syncSetups, name, "sync setup") as Record<string, unknown>,
299
+ storageConnections,
300
+ );
301
+ }
302
+ const names = Object.keys(syncSetups);
303
+ const activeSyncSetup = optionalCanonicalReference(value.activeSyncSetup, "activeSyncSetup");
304
+ if (names.length === 0) {
305
+ if (activeSyncSetup !== undefined) {
306
+ throw new Error("Invalid pi-sync settings: empty syncSetups cannot have activeSyncSetup.");
307
+ }
308
+ } else if (!activeSyncSetup || !Object.hasOwn(syncSetups, activeSyncSetup)) {
221
309
  throw new Error(
222
- `Invalid pi-sync settings: target "${selectedTarget}" references missing profile "${storageProfile}".`,
310
+ "Invalid pi-sync settings: activeSyncSetup must reference an existing own-property sync setup.",
223
311
  );
224
312
  }
225
- const kind = asOptionalString(profile.kind);
226
- if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
313
+ validateUniqueRemoteSyncSetups(syncSetups, storageConnections);
314
+ return value as PiSyncSettingsV3;
315
+ }
316
+
317
+ function validateStorageConnection(name: string, value: Record<string, unknown>) {
318
+ rejectLegacyFields(
319
+ value,
320
+ ["kind", "accessKeyId", "secretAccessKey", "sessionToken", "username", "password"],
321
+ `storage connection “${name}”`,
322
+ );
323
+ const type = requiredString(value.type, `storage connection “${name}” type`);
324
+ if (type !== "s3" && type !== "git" && type !== "webdav") {
325
+ throw new Error(`Invalid pi-sync settings: storage connection “${name}” has unsupported type.`);
326
+ }
327
+ const known = ["endpoint", "region", "remote", "url", "credentials"];
328
+ const allowed =
329
+ type === "s3"
330
+ ? new Set(["endpoint", "region", "credentials"])
331
+ : type === "git"
332
+ ? new Set(["remote"])
333
+ : new Set(["url", "credentials"]);
334
+ if (known.some((field) => Object.hasOwn(value, field) && !allowed.has(field))) {
227
335
  throw new Error(
228
- `Invalid pi-sync settings: profile "${storageProfile}" has unsupported kind "${kind}".`,
336
+ `Invalid pi-sync settings: ${type.toUpperCase()} storage connection “${name}” mixes backend fields.`,
229
337
  );
230
338
  }
231
- return {
232
- target: selectedTarget,
233
- storageProfile,
234
- storageKind: kind,
235
- settingsVersion: 2,
236
- endpoint:
237
- process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? asOptionalString(profile.endpoint),
238
- bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? asOptionalString(target.bucket),
239
- region:
240
- process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? asOptionalString(profile.region),
241
- accessKeyId:
242
- process.env.PI_SYNC_ACCESS_KEY_ID ??
243
- process.env.AWS_ACCESS_KEY_ID ??
244
- asOptionalString(profile.accessKeyId),
245
- secretAccessKey:
246
- process.env.PI_SYNC_SECRET_ACCESS_KEY ??
247
- process.env.AWS_SECRET_ACCESS_KEY ??
248
- asOptionalString(profile.secretAccessKey),
249
- sessionToken: selectSessionToken(asOptionalString(profile.sessionToken)),
250
- profile: process.env.PI_SYNC_PROFILE ?? asOptionalString(target.namespace) ?? selectedTarget,
251
- prefix: process.env.PI_SYNC_PREFIX ?? asOptionalString(target.prefix),
252
- autoSync: process.env.PI_SYNC_AUTO_SYNC ?? asOptionalBoolean(target.autoSync),
253
- syncFiles: target.syncFiles,
254
- syncSessions: process.env.PI_SYNC_SESSIONS ?? asOptionalBoolean(target.syncSessions),
255
- extraFiles: target.extraFiles,
256
- };
339
+ if (type === "git") {
340
+ if (!normalizeGitRemote(requiredString(value.remote, `Git remote for “${name}”`))) {
341
+ throw new Error(`Invalid pi-sync settings: Git remote for “${name}” is required.`);
342
+ }
343
+ return;
344
+ }
345
+ const credentials = requireRecord(
346
+ value.credentials,
347
+ `credentials for storage connection “${name}”`,
348
+ );
349
+ if (type === "webdav") {
350
+ normalizeWebDavUrl(requiredString(value.url, `WebDAV URL for “${name}”`));
351
+ const username = requiredString(credentials.username, `WebDAV username for “${name}”`);
352
+ const password = requiredSecret(credentials.password, `WebDAV password for “${name}”`);
353
+ if (
354
+ ["accessKeyId", "secretAccessKey", "sessionToken"].some((field) =>
355
+ Object.hasOwn(credentials, field),
356
+ )
357
+ ) {
358
+ throw new Error(`Invalid pi-sync settings: WebDAV credentials for “${name}” mix fields.`);
359
+ }
360
+ validateWebDavCredentials(username, password);
361
+ return;
362
+ }
363
+ normalizeS3Endpoint(requiredString(value.endpoint, `S3 endpoint for “${name}”`));
364
+ requiredString(value.region, `S3 region for “${name}”`);
365
+ requiredString(credentials.accessKeyId, `S3 access key id for “${name}”`);
366
+ requiredSecret(credentials.secretAccessKey, `S3 secret access key for “${name}”`);
367
+ optionalString(credentials.sessionToken, `S3 session token for “${name}”`);
368
+ if (["username", "password"].some((field) => Object.hasOwn(credentials, field))) {
369
+ throw new Error(`Invalid pi-sync settings: S3 credentials for “${name}” mix fields.`);
370
+ }
257
371
  }
258
372
 
259
- function isV2SettingsObject(value: Record<string, unknown>) {
260
- const hasV2Fields =
261
- Object.hasOwn(value, "profiles") ||
262
- Object.hasOwn(value, "targets") ||
263
- Object.hasOwn(value, "activeTarget");
264
- if (!hasV2Fields) return false;
265
- if (value.version !== 2) {
266
- throw new Error("Invalid pi-sync settings: version must be 2 for profiles and targets.");
373
+ function validateSyncSetup(
374
+ name: string,
375
+ value: Record<string, unknown>,
376
+ connections: Record<string, unknown>,
377
+ ) {
378
+ rejectLegacyFields(
379
+ value,
380
+ [
381
+ "profile",
382
+ "bucket",
383
+ "branch",
384
+ "path",
385
+ "prefix",
386
+ "directory",
387
+ "namespace",
388
+ "autoSync",
389
+ "syncFiles",
390
+ "syncSessions",
391
+ "extraFiles",
392
+ ],
393
+ `sync setup “${name}”`,
394
+ );
395
+ const storage = requireRecord(value.storage, `storage for sync setup “${name}”`);
396
+ const sync = requireRecord(value.sync, `sync policy for sync setup “${name}”`);
397
+ rejectLegacyFields(
398
+ storage,
399
+ ["profile", "prefix", "directory", "namespace"],
400
+ `storage for sync setup “${name}”`,
401
+ );
402
+ rejectLegacyFields(
403
+ sync,
404
+ ["autoSync", "syncFiles", "syncSessions", "extraFiles"],
405
+ `sync policy for sync setup “${name}”`,
406
+ );
407
+ const connectionName = requiredCanonicalReference(
408
+ storage.connection,
409
+ `storage connection reference for sync setup “${name}”`,
410
+ );
411
+ validateConfigName(connectionName, "storage connection reference");
412
+ const connection = ownObject<Record<string, unknown>>(connections, connectionName);
413
+ if (!connection) {
414
+ throw new Error(
415
+ `Invalid pi-sync settings: sync setup “${name}” references missing storage connection “${connectionName}”.`,
416
+ );
417
+ }
418
+ const type = connection.type;
419
+ normalizeStoragePath(requiredString(storage.path, `storage path for sync setup “${name}”`));
420
+ if (type === "s3") {
421
+ normalizeS3Bucket(requiredString(storage.bucket, `S3 bucket for sync setup “${name}”`));
422
+ if (Object.hasOwn(storage, "branch")) mixedSetupError("S3", name);
423
+ } else if (type === "git") {
424
+ normalizeGitBranch(requiredString(storage.branch, `Git branch for sync setup “${name}”`));
425
+ if (Object.hasOwn(storage, "bucket")) mixedSetupError("Git", name);
426
+ } else if (type === "webdav") {
427
+ if (Object.hasOwn(storage, "bucket") || Object.hasOwn(storage, "branch")) {
428
+ mixedSetupError("WebDAV", name);
429
+ }
430
+ }
431
+ if (!Object.hasOwn(sync, "include")) {
432
+ throw new Error(`Invalid pi-sync settings: sync setup “${name}” is missing sync.include.`);
433
+ }
434
+ normalizeSyncInclude(sync.include);
435
+ if (typeof sync.automatic !== "boolean") {
436
+ throw new Error(
437
+ `Invalid pi-sync settings: sync setup “${name}” sync.automatic must be boolean.`,
438
+ );
267
439
  }
268
- return true;
269
440
  }
270
441
 
271
- function validateUniqueRemoteTargets(targets: Record<string, unknown>) {
442
+ function mixedSetupError(type: string, name: string): never {
443
+ throw new Error(`Invalid pi-sync settings: ${type} sync setup “${name}” mixes backend fields.`);
444
+ }
445
+
446
+ export function validateUniqueRemoteSyncSetups(
447
+ setups: Record<string, unknown>,
448
+ connections: Record<string, unknown>,
449
+ ) {
272
450
  const identities = new Map<string, string>();
273
- for (const name of Object.keys(targets)) {
274
- const target = ownObject(targets, name);
275
- if (!target || typeof target.profile !== "string" || typeof target.bucket !== "string")
276
- continue;
277
- const identity = effectiveTargetRemoteIdentity(target, name);
451
+ for (const name of Object.keys(setups)) {
452
+ const setup = requireOwnObject(setups, name, "sync setup") as unknown as SyncSetupSettings;
453
+ const connection = requireOwnObject(
454
+ connections,
455
+ setup.storage.connection,
456
+ "storage connection",
457
+ ) as unknown as StorageConnectionSettings;
458
+ const identity = effectiveSyncSetupRemoteIdentity(setup, connection);
278
459
  const existing = identities.get(identity);
279
460
  if (existing) {
280
461
  throw new Error(
281
- `Invalid pi-sync settings: targets "${existing}" and "${name}" use the same remote destination.`,
462
+ `Invalid pi-sync settings: sync setups “${existing}” and “${name}” use the same normalized remote location.`,
282
463
  );
283
464
  }
284
465
  identities.set(identity, name);
285
466
  }
286
467
  }
287
468
 
288
- export function effectiveTargetRemoteIdentity(target: Record<string, unknown>, name: string) {
289
- const profile = typeof target.profile === "string" ? target.profile.trim() : "";
290
- const bucket = normalizeRemoteKeySegment(
291
- process.env.PI_SYNC_BUCKET ??
292
- process.env.R2_BUCKET ??
293
- (typeof target.bucket === "string" ? target.bucket : ""),
294
- );
295
- const prefix = normalizeRemoteKeySegment(
296
- process.env.PI_SYNC_PREFIX ??
297
- (typeof target.prefix === "string" ? target.prefix : DEFAULT_PREFIX),
298
- );
299
- const namespace = normalizeRemoteKeySegment(
300
- process.env.PI_SYNC_PROFILE ?? (typeof target.namespace === "string" ? target.namespace : name),
301
- );
302
- return JSON.stringify([profile, bucket, prefix, namespace]);
469
+ export function effectiveSyncSetupRemoteIdentity(
470
+ setup: SyncSetupSettings,
471
+ connection: StorageConnectionSettings,
472
+ ) {
473
+ const storagePath = normalizeStoragePath(setup.storage.path);
474
+ if (connection.type === "git") {
475
+ return JSON.stringify([
476
+ "git",
477
+ normalizeGitRemoteIdentity(connection.remote),
478
+ normalizeGitBranch(setup.storage.branch),
479
+ normalizeGitDirectory(storagePath),
480
+ ]);
481
+ }
482
+ if (connection.type === "webdav") {
483
+ return JSON.stringify([
484
+ "webdav",
485
+ normalizeWebDavIdentityUrl(connection.url),
486
+ connection.credentials.username.trim(),
487
+ normalizeWebDavPath(storagePath),
488
+ ]);
489
+ }
490
+ return JSON.stringify([
491
+ "s3",
492
+ normalizeEndpointIdentity(connection.endpoint),
493
+ normalizeS3Bucket(setup.storage.bucket),
494
+ storagePath,
495
+ ]);
303
496
  }
304
497
 
305
- function normalizeRemoteKeySegment(value: string) {
306
- return trimSlashes(value.trim());
498
+ function rejectLegacyFields(
499
+ value: Record<string, unknown>,
500
+ fields: readonly string[],
501
+ context: string,
502
+ ) {
503
+ const field = fields.find((candidate) => Object.hasOwn(value, candidate));
504
+ if (field) {
505
+ throw new Error(
506
+ `Invalid pi-sync settings: ${context} contains unsupported version 1/2 field “${field}”.`,
507
+ );
508
+ }
307
509
  }
308
510
 
309
- function requireNamedObjectMap(value: unknown, field: string): Record<string, unknown> {
310
- if (!value || typeof value !== "object" || Array.isArray(value)) {
311
- throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
312
- }
313
- for (const name of Object.keys(value)) validateConfigName(name, field.slice(0, -1));
314
- return value as Record<string, unknown>;
511
+ function requireNamedObjectMap(value: unknown, field: string, itemLabel: string) {
512
+ const result = requireRecord(value, field);
513
+ for (const name of Object.keys(result)) validateConfigName(name, itemLabel);
514
+ return result;
315
515
  }
316
516
 
317
- function ownObject(
318
- value: Record<string, unknown>,
319
- key: string,
320
- ): Record<string, unknown> | undefined {
517
+ function requireOwnObject(value: Record<string, unknown>, key: string, label: string) {
518
+ const item = ownObject(value, key);
519
+ if (!item) throw new Error(`Invalid pi-sync settings: ${label} “${key}” must be an object.`);
520
+ return item;
521
+ }
522
+
523
+ function ownObject<T extends object>(value: Record<string, unknown>, key: string): T | undefined {
321
524
  if (!Object.hasOwn(value, key)) return undefined;
322
525
  const item = value[key];
323
- return item && typeof item === "object" && !Array.isArray(item)
324
- ? (item as Record<string, unknown>)
325
- : undefined;
526
+ return item && typeof item === "object" && !Array.isArray(item) ? (item as T) : undefined;
326
527
  }
327
528
 
328
- function validateConfigName(value: string, field: string) {
529
+ function requireRecord(value: unknown, field: string): Record<string, unknown> {
530
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
531
+ throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
532
+ }
533
+ return value as Record<string, unknown>;
534
+ }
535
+
536
+ export function validateConfigName(value: string, field: string) {
329
537
  if (
330
538
  !value.trim() ||
539
+ value !== value.trim() ||
331
540
  value.length > 100 ||
332
541
  value === "__proto__" ||
333
542
  value === "prototype" ||
334
543
  value === "constructor" ||
335
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Config identifiers cannot render terminal controls.
336
- /[\u0000-\u001f\u007f-\u009f]/u.test(value)
544
+ hasControlCharacter(value)
337
545
  ) {
338
546
  throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
339
547
  }
340
548
  }
341
549
 
342
- function asOptionalString(value: unknown) {
343
- if (value === undefined) return undefined;
344
- if (typeof value !== "string") throw new Error("Invalid pi-sync settings: expected a string.");
550
+ function requiredString(value: unknown, field: string) {
551
+ if (typeof value !== "string" || !value.trim() || hasControlCharacter(value)) {
552
+ throw new Error(`Invalid pi-sync settings: ${field} must be a non-empty string.`);
553
+ }
554
+ return value.trim();
555
+ }
556
+
557
+ function requiredCanonicalReference(value: unknown, field: string) {
558
+ const normalized = requiredString(value, field);
559
+ if (value !== normalized) {
560
+ throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
561
+ }
562
+ return normalized;
563
+ }
564
+
565
+ function optionalCanonicalReference(value: unknown, field: string) {
566
+ const normalized = optionalString(value, field);
567
+ if (normalized !== undefined && value !== normalized) {
568
+ throw new Error(`Invalid pi-sync settings: ${field} must not have surrounding whitespace.`);
569
+ }
570
+ return normalized;
571
+ }
572
+
573
+ function requiredSecret(value: unknown, field: string) {
574
+ if (typeof value !== "string" || !value || hasControlCharacter(value)) {
575
+ throw new Error(`Invalid pi-sync settings: ${field} must be configured.`);
576
+ }
345
577
  return value;
346
578
  }
347
579
 
348
- function asOptionalBoolean(value: unknown) {
580
+ function optionalString(value: unknown, field: string) {
349
581
  if (value === undefined) return undefined;
350
- if (typeof value !== "boolean") throw new Error("Invalid pi-sync settings: expected a boolean.");
351
- return value;
582
+ if (typeof value !== "string" || hasControlCharacter(value)) {
583
+ throw new Error(`Invalid pi-sync settings: ${field} must be a string.`);
584
+ }
585
+ return value.trim() || undefined;
586
+ }
587
+
588
+ export function normalizeStoragePath(value: string) {
589
+ const normalized = value.trim().replace(/^\/+|\/+$/gu, "");
590
+ if (
591
+ !normalized ||
592
+ normalized.length > 1024 ||
593
+ normalized.startsWith("-") ||
594
+ normalized.includes("\\") ||
595
+ hasControlCharacter(normalized) ||
596
+ normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
597
+ ) {
598
+ throw new Error("Invalid pi-sync settings: storage.path must be a safe relative path.");
599
+ }
600
+ return normalized;
601
+ }
602
+
603
+ function normalizeS3Endpoint(value: string) {
604
+ let url: URL;
605
+ try {
606
+ url = new URL(value.trim());
607
+ } catch {
608
+ throw new Error("Invalid pi-sync S3 endpoint.");
609
+ }
610
+ const loopback =
611
+ url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
612
+ if (
613
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
614
+ url.username ||
615
+ url.password ||
616
+ url.search ||
617
+ url.hash
618
+ ) {
619
+ throw new Error("Invalid pi-sync S3 endpoint: HTTPS is required except for loopback.");
620
+ }
621
+ url.pathname = url.pathname.replace(/\/+$/gu, "");
622
+ return url.toString().replace(/\/$/u, "");
623
+ }
624
+
625
+ function normalizeS3Bucket(value: string | undefined) {
626
+ const bucket = requiredString(value, "S3 bucket");
627
+ if (bucket.includes("/") || bucket.includes("\\") || bucket.startsWith("-")) {
628
+ throw new Error("Invalid pi-sync S3 bucket.");
629
+ }
630
+ return bucket;
631
+ }
632
+
633
+ function normalizeEndpointIdentity(endpoint: string) {
634
+ try {
635
+ const url = new URL(endpoint.trim());
636
+ url.hostname = url.hostname.toLowerCase();
637
+ url.pathname = url.pathname.replace(/\/+$/gu, "");
638
+ return url.toString().replace(/\/$/u, "");
639
+ } catch {
640
+ return endpoint.trim();
641
+ }
352
642
  }
353
643
 
354
644
  export async function configuredSessionDir() {
355
- const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
356
- if (envSessionDir) return expandHome(envSessionDir);
357
645
  const settings = await readJsonIfExists<{ sessionDir?: string }>(
358
646
  path.join(agentDir(), "settings.json"),
359
647
  );
@@ -365,9 +653,6 @@ export async function sessionDirForApply(
365
653
  snapshot: Snapshot,
366
654
  ) {
367
655
  const contextSessionDir = sessionDirFromContext(ctx);
368
- const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
369
- if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
370
-
371
656
  const localSessionDir = await configuredSessionDir();
372
657
  if (
373
658
  contextSessionDir &&
@@ -391,10 +676,17 @@ function sessionDirFromSnapshot(snapshot: Snapshot) {
391
676
  }
392
677
  }
393
678
 
679
+ function decodeBase64Strict(value: string, filePath: string) {
680
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
681
+ throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
682
+ }
683
+ return Buffer.from(value, "base64");
684
+ }
685
+
394
686
  export async function readState(profile: string): Promise<SyncState> {
395
687
  return (
396
688
  (await readJsonIfExists<SyncState>(statePath(profile))) ?? {
397
- version: VERSION,
689
+ version: STATE_VERSION,
398
690
  profile,
399
691
  lastFileHashes: {},
400
692
  }
@@ -405,96 +697,49 @@ export async function writeState(profile: string, state: SyncState) {
405
697
  await writeJson(statePath(profile), state);
406
698
  }
407
699
 
408
- export async function readStateForConfig(config: SyncConfig): Promise<SyncState> {
409
- if (config.settingsVersion !== 2) return readState(config.profile);
410
- const destination = statePathForConfig(config);
411
- const state = await readJsonIfExists<SyncState>(destination);
412
- if (state) return state;
700
+ export async function readStateForConfig(config: AnySyncConfig): Promise<SyncState> {
413
701
  return (
414
- (await migrateLegacyV2State(config, destination)) ?? {
415
- version: VERSION,
416
- profile: config.profile,
702
+ (await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
703
+ version: STATE_VERSION,
704
+ profile: config.snapshotIdentity,
417
705
  lastFileHashes: {},
418
706
  }
419
707
  );
420
708
  }
421
709
 
422
- export async function writeStateForConfig(config: SyncConfig, state: SyncState) {
710
+ export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
423
711
  await writeJson(statePathForConfig(config), state);
424
712
  }
425
713
 
426
- export function statePathForConfig(config: SyncConfig) {
427
- if (config.settingsVersion !== 2) return statePath(config.profile);
428
- const target = config.target ?? DEFAULT_PROFILE;
429
- const identity = JSON.stringify([
430
- target,
431
- normalizeEndpointIdentity(config.endpoint),
432
- normalizeRemoteKeySegment(config.bucket),
433
- normalizeRemoteKeySegment(config.prefix),
434
- normalizeRemoteKeySegment(config.profile),
435
- ]);
436
- const hash = createHash("sha256").update(identity).digest("hex").slice(0, 10);
437
- return path.join(stateDir(), "targets", `${safeName(target)}-${hash}.state.json`);
438
- }
439
-
440
- export function statePathForPartialConfig(partial: PartialConfig) {
441
- const profile = normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE;
442
- if (partial.settingsVersion !== 2) return statePath(profile);
443
- return statePathForConfig({
444
- settingsVersion: 2,
445
- target: partial.target ?? DEFAULT_PROFILE,
446
- endpoint: normalizeConfiguredString(partial.endpoint) ?? "",
447
- bucket: normalizeConfiguredString(partial.bucket) ?? "",
448
- prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
449
- profile,
450
- } as SyncConfig);
451
- }
452
-
453
- async function migrateLegacyV2State(config: SyncConfig, destination: string) {
454
- const target = config.target ?? DEFAULT_PROFILE;
455
- const legacyHash = createHash("sha256").update(target).digest("hex").slice(0, 10);
456
- const legacyPath = path.join(
457
- stateDir(),
458
- "targets",
459
- `${safeName(target)}-${legacyHash}.state.json`,
460
- );
461
- const claimPath = `${legacyPath}.migrating-to-${path.basename(destination)}`;
462
- if (!(await pathExists(claimPath))) {
463
- try {
464
- await fs.rename(legacyPath, claimPath);
465
- } catch (error) {
466
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
467
- }
468
- }
469
- if (!(await pathExists(claimPath))) return readJsonIfExists<SyncState>(destination);
470
-
471
- await fs.mkdir(path.dirname(destination), { recursive: true });
472
- try {
473
- await fs.link(claimPath, destination);
474
- } catch (error) {
475
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
476
- }
477
- const migrated = await readJsonIfExists<SyncState>(destination);
478
- if (migrated) await fs.rm(claimPath, { force: true });
479
- return migrated;
480
- }
481
-
482
- async function pathExists(filePath: string) {
483
- try {
484
- await fs.lstat(filePath);
485
- return true;
486
- } catch (error) {
487
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
488
- throw error;
489
- }
490
- }
491
-
492
- function normalizeEndpointIdentity(endpoint: string) {
493
- const normalized = endpoint.trim();
494
- try {
495
- return new URL(normalized).toString();
496
- } catch {
497
- return normalized;
714
+ export function statePathForConfig(config: AnySyncConfig) {
715
+ const identity = backendIdentityCoordinates(config);
716
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, 16);
717
+ return path.join(stateDir(), "setups", `${config.backend.type}-${hash}.state.json`);
718
+ }
719
+
720
+ function backendIdentityCoordinates(config: AnySyncConfig) {
721
+ switch (config.backend.type) {
722
+ case "s3":
723
+ return JSON.stringify([
724
+ "s3",
725
+ normalizeEndpointIdentity(config.backend.profile.endpoint),
726
+ config.backend.destination.bucket,
727
+ config.storagePath,
728
+ ]);
729
+ case "git":
730
+ return JSON.stringify([
731
+ "git",
732
+ normalizeGitRemoteIdentity(config.backend.profile.remote),
733
+ config.backend.destination.branch,
734
+ config.storagePath,
735
+ ]);
736
+ case "webdav":
737
+ return JSON.stringify([
738
+ "webdav",
739
+ normalizeWebDavIdentityUrl(config.backend.profile.url),
740
+ config.backend.profile.username,
741
+ config.storagePath,
742
+ ]);
498
743
  }
499
744
  }
500
745
 
@@ -510,128 +755,37 @@ export function stateDir() {
510
755
  return path.join(agentDir(), ".pisync");
511
756
  }
512
757
 
513
- export function localConfigTemplate(): Record<string, unknown> {
758
+ export function localConfigTemplate(): PiSyncSettingsV3 {
514
759
  return {
515
- version: 2,
516
- activeTarget: DEFAULT_PROFILE,
517
- targetSwitchAction: DEFAULT_TARGET_SWITCH_ACTION,
518
- profiles: {
519
- [DEFAULT_PROFILE]: {
520
- kind: "r2",
521
- endpoint: "https://<account-id>.r2.cloudflarestorage.com",
522
- region: DEFAULT_REGION,
523
- accessKeyId: "<access-key-id>",
524
- secretAccessKey: "<secret-access-key>",
525
- },
526
- },
527
- targets: {
528
- [DEFAULT_PROFILE]: {
529
- profile: DEFAULT_PROFILE,
530
- bucket: "pi-sync",
531
- namespace: DEFAULT_PROFILE,
532
- prefix: DEFAULT_PREFIX,
533
- autoSync: true,
534
- syncFiles: [...DEFAULT_SYNC_FILES],
535
- syncSessions: false,
536
- extraFiles: [],
537
- },
538
- },
760
+ version: 3,
761
+ onSwitch: DEFAULT_ON_SWITCH,
762
+ storageConnections: {},
763
+ syncSetups: {},
539
764
  };
540
765
  }
541
766
 
542
767
  export async function readLocalConfigDocument() {
543
- return readMigratingLocalConfigDocument(validateConfigDocumentForMigration);
768
+ const document = await readMigratingLocalConfigDocument((settings) => {
769
+ validateSettingsDocument(settings);
770
+ });
771
+ if (document) validateSettingsDocument(document.parsed);
772
+ return document;
544
773
  }
545
774
 
546
- export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
547
- return (await readLocalConfigDocument())?.parsed;
548
- }
549
-
550
- function validateConfigDocumentForMigration(settings: Record<string, unknown>) {
551
- if (!isV2SettingsObject(settings)) {
552
- for (const field of [
553
- "endpoint",
554
- "bucket",
555
- "region",
556
- "accessKeyId",
557
- "secretAccessKey",
558
- "sessionToken",
559
- "profile",
560
- "prefix",
561
- ] as const) {
562
- asOptionalString(settings[field]);
563
- }
564
- for (const field of ["autoSync", "syncSessions"] as const) {
565
- const value = settings[field];
566
- if (value !== undefined && typeof value !== "boolean" && typeof value !== "string") {
567
- throw new Error(`Invalid pi-sync settings: ${field} must be a boolean or string.`);
568
- }
569
- }
570
- normalizeSyncFiles(settings.syncFiles);
571
- normalizeExtraFiles(settings.extraFiles);
572
- return;
573
- }
574
-
575
- normalizeTargetSwitchAction(settings.targetSwitchAction);
576
- const profiles = requireNamedObjectMap(settings.profiles, "profiles");
577
- const targets = requireNamedObjectMap(settings.targets, "targets");
578
- validateUniqueRemoteTargets(targets);
579
- for (const [name, value] of Object.entries(profiles)) {
580
- const profile = ownObject(profiles, name);
581
- if (!profile || value !== profile) {
582
- throw new Error(`Invalid pi-sync settings: storage profile "${name}" must be an object.`);
583
- }
584
- const kind = asOptionalString(profile.kind);
585
- if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
586
- throw new Error(
587
- `Invalid pi-sync settings: profile "${name}" has unsupported kind "${kind}".`,
588
- );
589
- }
590
- for (const field of [
591
- "endpoint",
592
- "region",
593
- "accessKeyId",
594
- "secretAccessKey",
595
- "sessionToken",
596
- ] as const) {
597
- asOptionalString(profile[field]);
598
- }
599
- }
600
- for (const [name, value] of Object.entries(targets)) {
601
- const target = ownObject(targets, name);
602
- if (!target || value !== target) {
603
- throw new Error(`Invalid pi-sync settings: target "${name}" must be an object.`);
604
- }
605
- const profileName = normalizeOptionalString(asOptionalString(target.profile));
606
- if (!profileName || !Object.hasOwn(profiles, profileName)) {
607
- throw new Error(`Invalid pi-sync settings: target "${name}" references a missing profile.`);
608
- }
609
- for (const field of ["bucket", "prefix", "namespace"] as const) {
610
- asOptionalString(target[field]);
611
- }
612
- asOptionalBoolean(target.autoSync);
613
- asOptionalBoolean(target.syncSessions);
614
- normalizeSyncFiles(target.syncFiles);
615
- normalizeExtraFiles(target.extraFiles);
616
- }
617
- const activeTarget = normalizeOptionalString(asOptionalString(settings.activeTarget));
618
- if (Object.keys(targets).length === 0) {
619
- if (activeTarget) {
620
- throw new Error("Invalid pi-sync settings: targetless settings cannot have activeTarget.");
621
- }
622
- return;
623
- }
624
- if (!activeTarget || !Object.hasOwn(targets, activeTarget)) {
625
- throw new Error("Invalid pi-sync settings: activeTarget must reference an existing target.");
626
- }
775
+ export async function readLocalConfigObject(): Promise<PiSyncSettingsV3 | undefined> {
776
+ return (await readLocalConfigDocument())?.parsed as PiSyncSettingsV3 | undefined;
627
777
  }
628
778
 
629
779
  let configUpdateQueue: Promise<void> = Promise.resolve();
630
780
 
631
781
  export function updateLocalConfig(
632
- update: (current: Record<string, unknown>) => Record<string, unknown>,
782
+ update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
783
+ signal?: AbortSignal,
633
784
  ) {
634
- const operation = configUpdateQueue.then(() => performLocalConfigUpdate(update));
785
+ const operation = configUpdateQueue.then(() => {
786
+ signal?.throwIfAborted();
787
+ return performLocalConfigUpdate(update, signal);
788
+ });
635
789
  configUpdateQueue = operation.then(
636
790
  () => undefined,
637
791
  () => undefined,
@@ -640,30 +794,24 @@ export function updateLocalConfig(
640
794
  }
641
795
 
642
796
  async function performLocalConfigUpdate(
643
- update: (current: Record<string, unknown>) => Record<string, unknown>,
797
+ update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
798
+ signal?: AbortSignal,
644
799
  ) {
645
- const current = (await readLocalConfigObject()) ?? localConfigTemplate();
646
- const next = update({ ...current });
647
- await writeLocalConfigObject(next);
648
- return next;
800
+ return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
649
801
  }
650
802
 
651
- export function writeLocalConfigObject(value: Record<string, unknown>) {
652
- return withLocalConfigFileLock(() => writeLocalConfigObjectUnlocked(value));
653
- }
654
-
655
- async function writeLocalConfigObjectUnlocked(value: Record<string, unknown>) {
803
+ export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
804
+ validateSettingsDocument(value as Record<string, unknown>);
656
805
  const configPath = localConfigPath();
657
806
  await fs.mkdir(path.dirname(configPath), { recursive: true });
658
807
  try {
659
808
  const stat = await fs.lstat(configPath);
660
809
  if (stat.isSymbolicLink())
661
- throw new Error(`Refusing to overwrite symlinked pi-sync config: ${configPath}`);
662
- if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
810
+ throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
811
+ if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
663
812
  } catch (error) {
664
813
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
665
814
  }
666
-
667
815
  const temporaryPath = path.join(
668
816
  path.dirname(configPath),
669
817
  `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
@@ -707,16 +855,10 @@ async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
707
855
 
708
856
  export async function writeJson(filePath: string, value: unknown) {
709
857
  await fs.mkdir(path.dirname(filePath), { recursive: true });
710
- await fs.writeFile(filePath, `${JSON.stringify(value, null, "\t")}\n`);
711
- }
712
-
713
- function selectSessionToken(fileSessionToken: string | undefined) {
714
- if (hasEnv("PI_SYNC_SESSION_TOKEN"))
715
- return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
716
- return (
717
- normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ??
718
- normalizeOptionalString(fileSessionToken)
719
- );
858
+ const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
859
+ await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
860
+ if (process.platform !== "win32") await fs.chmod(temp, 0o600);
861
+ await fs.rename(temp, filePath);
720
862
  }
721
863
 
722
864
  export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
@@ -726,10 +868,10 @@ export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?:
726
868
  ];
727
869
  }
728
870
 
729
- export function syncSessionsWarnings(config: { syncSessions?: boolean }) {
730
- if (!config.syncSessions) return [];
871
+ export function syncSessionsWarnings(config: { include: readonly string[] }) {
872
+ if (!config.include.includes("sessions")) return [];
731
873
  return [
732
- "sessions: enabled; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
874
+ "sessions: included; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
733
875
  ];
734
876
  }
735
877
 
@@ -746,22 +888,6 @@ export function isCloudflareR2Endpoint(endpoint: string | undefined) {
746
888
  }
747
889
  }
748
890
 
749
- function normalizeOptionalString(value: string | undefined) {
750
- const normalized = value?.trim();
751
- return normalized ? normalized : undefined;
752
- }
753
-
754
- function normalizeConfiguredString(value: string | undefined) {
755
- const normalized = normalizeOptionalString(value);
756
- return normalized && !/^<[^>]+>$/u.test(normalized) && !normalized.includes("<account-id>")
757
- ? normalized
758
- : undefined;
759
- }
760
-
761
- function hasEnv(name: string) {
762
- return Object.hasOwn(process.env, name);
763
- }
764
-
765
891
  export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
766
892
  if (value === undefined) return defaultValue;
767
893
  if (typeof value === "boolean") return value;
@@ -773,6 +899,9 @@ export function isExplicitlyEnabled(value: boolean | string | undefined) {
773
899
  return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
774
900
  }
775
901
 
776
- export function isMissingConfigError(error: unknown) {
777
- return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
902
+ function hasControlCharacter(value: string) {
903
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Stored settings cannot contain controls.
904
+ return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
778
905
  }
906
+
907
+ export { isMissingConfigError } from "./config-errors.js";