@narumitw/pi-sync 0.25.0 → 0.29.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
@@ -1,19 +1,35 @@
1
- import type { Dirent } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
- import { isDeniedPath, safeName } from "./paths.js";
5
+ import {
6
+ type ExtensionCommandContext,
7
+ type ExtensionContext,
8
+ getAgentDir,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { safeName } from "./paths.js";
11
+ import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
7
12
  import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
8
13
 
14
+ export { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
15
+
9
16
  const VERSION = 1;
10
17
  const DEFAULT_PROFILE = "default";
11
18
  const DEFAULT_PREFIX = "pi-sync";
12
19
  const DEFAULT_REGION = "auto";
13
- const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md", "APPEND_SYSTEM.md"]);
14
- const TOP_LEVEL_FILE_NAMES = new Set([...TOP_LEVEL_FILES].map((name) => name.toLowerCase()));
15
- const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
16
- const RESERVED_TOP_LEVEL_NAMES = new Set([...TOP_LEVEL_DIRS, "sessions"]);
20
+
21
+ export const DEPRECATED_PI_SYNC_ENV_NAMES = [
22
+ "PI_SYNC_ENDPOINT",
23
+ "PI_SYNC_BUCKET",
24
+ "PI_SYNC_REGION",
25
+ "PI_SYNC_ACCESS_KEY_ID",
26
+ "PI_SYNC_SECRET_ACCESS_KEY",
27
+ "PI_SYNC_SESSION_TOKEN",
28
+ "PI_SYNC_PROFILE",
29
+ "PI_SYNC_PREFIX",
30
+ "PI_SYNC_AUTO_SYNC",
31
+ "PI_SYNC_SESSIONS",
32
+ ] as const;
17
33
 
18
34
  function trimSlashes(value: string) {
19
35
  return value.replace(/^\/+|\/+$/g, "");
@@ -40,12 +56,12 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
40
56
  : undefined;
41
57
  }
42
58
 
43
- export async function loadConfigInternal(): Promise<SyncConfig> {
44
- const partial = await loadPartialConfig();
45
- const endpoint = partial.endpoint;
46
- const bucket = partial.bucket;
47
- const accessKeyId = partial.accessKeyId;
48
- const secretAccessKey = partial.secretAccessKey;
59
+ export async function loadConfigInternal(targetName?: string): Promise<SyncConfig> {
60
+ const partial = await loadPartialConfig(targetName);
61
+ const endpoint = normalizeConfiguredString(partial.endpoint);
62
+ const bucket = normalizeConfiguredString(partial.bucket);
63
+ const accessKeyId = normalizeConfiguredString(partial.accessKeyId);
64
+ const secretAccessKey = normalizeConfiguredString(partial.secretAccessKey);
49
65
  const missing = [
50
66
  ["endpoint", endpoint],
51
67
  ["bucket", bucket],
@@ -55,59 +71,267 @@ export async function loadConfigInternal(): Promise<SyncConfig> {
55
71
  .filter(([, value]) => !value)
56
72
  .map(([name]) => name);
57
73
  if (missing.length > 0) {
58
- throw new Error(`Missing pi-sync config: ${missing.join(", ")}. Run /pisync init or set PI_SYNC_* environment variables.`);
74
+ throw new Error(
75
+ `Missing pi-sync config: ${missing.join(", ")}. Use /sync setup or edit ${localConfigPath()}.`,
76
+ );
77
+ }
78
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
79
+ throw new Error("Missing pi-sync config after validation.");
59
80
  }
60
81
 
61
82
  return {
62
- endpoint: endpoint!,
63
- bucket: bucket!,
64
- region: partial.region ?? DEFAULT_REGION,
65
- accessKeyId: accessKeyId!,
66
- secretAccessKey: secretAccessKey!,
67
- sessionToken: partial.sessionToken,
68
- profile: partial.profile ?? DEFAULT_PROFILE,
69
- prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
83
+ endpoint,
84
+ bucket,
85
+ region: normalizeOptionalString(partial.region) ?? DEFAULT_REGION,
86
+ accessKeyId,
87
+ secretAccessKey,
88
+ sessionToken: normalizeOptionalString(partial.sessionToken),
89
+ profile: normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE,
90
+ prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
91
+ target: partial.target ?? DEFAULT_PROFILE,
92
+ storageProfile: partial.storageProfile ?? DEFAULT_PROFILE,
93
+ storageKind: partial.storageKind,
94
+ autoSync: isEnabled(partial.autoSync, true),
95
+ settingsVersion: partial.settingsVersion ?? 1,
96
+ syncFiles: normalizeSyncFiles(partial.syncFiles),
70
97
  syncSessions: isExplicitlyEnabled(partial.syncSessions),
71
98
  extraFiles: normalizeExtraFiles(partial.extraFiles),
72
99
  };
73
100
  }
74
101
 
75
- export async function loadConfig(): Promise<SyncConfig> {
76
- return loadConfigInternal();
102
+ export async function loadConfig(targetName?: string): Promise<SyncConfig> {
103
+ return loadConfigInternal(targetName);
104
+ }
105
+
106
+ export async function configuredTargetNames() {
107
+ const settings = await readLocalConfigObject();
108
+ if (!settings) return [];
109
+ if (!isV2SettingsObject(settings)) return [];
110
+ return Object.keys(requireNamedObjectMap(settings.targets, "targets")).sort((left, right) =>
111
+ left.localeCompare(right),
112
+ );
113
+ }
114
+
115
+ export async function loadPartialConfig(targetName?: string): Promise<PartialConfig> {
116
+ const fileConfig = (await readLocalConfigObject()) ?? {};
117
+ if (isV2SettingsObject(fileConfig)) return resolveV2PartialConfig(fileConfig, targetName);
118
+ if (targetName !== undefined) {
119
+ throw new Error("--target requires version 2 pi-sync settings with named targets.");
120
+ }
121
+ return resolveLegacyPartialConfig(fileConfig as PartialConfig);
122
+ }
123
+
124
+ export function deprecatedPiSyncEnvironmentNames() {
125
+ return DEPRECATED_PI_SYNC_ENV_NAMES.filter((name) => hasEnv(name));
126
+ }
127
+
128
+ export function deprecatedPiSyncEnvironmentWarnings() {
129
+ const names = deprecatedPiSyncEnvironmentNames();
130
+ if (names.length === 0) return [];
131
+ return [
132
+ `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.`,
133
+ ];
77
134
  }
78
135
 
79
- export async function loadPartialConfig(): Promise<PartialConfig> {
80
- const fileConfig = (await readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
136
+ function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
81
137
  return {
82
138
  ...fileConfig,
139
+ target: DEFAULT_PROFILE,
140
+ storageProfile: DEFAULT_PROFILE,
141
+ settingsVersion: 1,
83
142
  endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
84
143
  bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
85
144
  region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
86
- accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
87
- secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig.secretAccessKey,
145
+ accessKeyId:
146
+ process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
147
+ secretAccessKey:
148
+ process.env.PI_SYNC_SECRET_ACCESS_KEY ??
149
+ process.env.AWS_SECRET_ACCESS_KEY ??
150
+ fileConfig.secretAccessKey,
88
151
  sessionToken: selectSessionToken(fileConfig.sessionToken),
89
152
  profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
90
153
  prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
91
154
  autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
92
155
  syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
93
- extraFiles: fileConfig.extraFiles,
94
156
  };
95
157
  }
96
158
 
159
+ function resolveV2PartialConfig(
160
+ settings: Record<string, unknown>,
161
+ targetName?: string,
162
+ ): PartialConfig {
163
+ const targets = requireNamedObjectMap(settings.targets, "targets");
164
+ const profiles = requireNamedObjectMap(settings.profiles, "profiles");
165
+ validateUniqueRemoteTargets(targets);
166
+ const selectedTarget =
167
+ targetName ?? normalizeOptionalString(asOptionalString(settings.activeTarget));
168
+ if (!selectedTarget) throw new Error("Invalid pi-sync settings: activeTarget is required.");
169
+ validateConfigName(selectedTarget, "target");
170
+ const target = ownObject(targets, selectedTarget);
171
+ if (!target)
172
+ throw new Error(`Invalid pi-sync settings: target "${selectedTarget}" was not found.`);
173
+ const storageProfile = normalizeOptionalString(asOptionalString(target.profile));
174
+ if (!storageProfile) {
175
+ throw new Error(`Invalid pi-sync settings: target "${selectedTarget}" is missing profile.`);
176
+ }
177
+ validateConfigName(storageProfile, "storage profile");
178
+ const profile = ownObject(profiles, storageProfile);
179
+ if (!profile) {
180
+ throw new Error(
181
+ `Invalid pi-sync settings: target "${selectedTarget}" references missing profile "${storageProfile}".`,
182
+ );
183
+ }
184
+ const kind = asOptionalString(profile.kind);
185
+ if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
186
+ throw new Error(
187
+ `Invalid pi-sync settings: profile "${storageProfile}" has unsupported kind "${kind}".`,
188
+ );
189
+ }
190
+ return {
191
+ target: selectedTarget,
192
+ storageProfile,
193
+ storageKind: kind,
194
+ settingsVersion: 2,
195
+ endpoint:
196
+ process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? asOptionalString(profile.endpoint),
197
+ bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? asOptionalString(target.bucket),
198
+ region:
199
+ process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? asOptionalString(profile.region),
200
+ accessKeyId:
201
+ process.env.PI_SYNC_ACCESS_KEY_ID ??
202
+ process.env.AWS_ACCESS_KEY_ID ??
203
+ asOptionalString(profile.accessKeyId),
204
+ secretAccessKey:
205
+ process.env.PI_SYNC_SECRET_ACCESS_KEY ??
206
+ process.env.AWS_SECRET_ACCESS_KEY ??
207
+ asOptionalString(profile.secretAccessKey),
208
+ sessionToken: selectSessionToken(asOptionalString(profile.sessionToken)),
209
+ profile: process.env.PI_SYNC_PROFILE ?? asOptionalString(target.namespace) ?? selectedTarget,
210
+ prefix: process.env.PI_SYNC_PREFIX ?? asOptionalString(target.prefix),
211
+ autoSync: process.env.PI_SYNC_AUTO_SYNC ?? asOptionalBoolean(target.autoSync),
212
+ syncFiles: target.syncFiles,
213
+ syncSessions: process.env.PI_SYNC_SESSIONS ?? asOptionalBoolean(target.syncSessions),
214
+ extraFiles: target.extraFiles,
215
+ };
216
+ }
217
+
218
+ function isV2SettingsObject(value: Record<string, unknown>) {
219
+ const hasV2Fields =
220
+ Object.hasOwn(value, "profiles") ||
221
+ Object.hasOwn(value, "targets") ||
222
+ Object.hasOwn(value, "activeTarget");
223
+ if (!hasV2Fields) return false;
224
+ if (value.version !== 2) {
225
+ throw new Error("Invalid pi-sync settings: version must be 2 for profiles and targets.");
226
+ }
227
+ return true;
228
+ }
229
+
230
+ function validateUniqueRemoteTargets(targets: Record<string, unknown>) {
231
+ const identities = new Map<string, string>();
232
+ for (const name of Object.keys(targets)) {
233
+ const target = ownObject(targets, name);
234
+ if (!target || typeof target.profile !== "string" || typeof target.bucket !== "string")
235
+ continue;
236
+ const identity = effectiveTargetRemoteIdentity(target, name);
237
+ const existing = identities.get(identity);
238
+ if (existing) {
239
+ throw new Error(
240
+ `Invalid pi-sync settings: targets "${existing}" and "${name}" use the same remote destination.`,
241
+ );
242
+ }
243
+ identities.set(identity, name);
244
+ }
245
+ }
246
+
247
+ export function effectiveTargetRemoteIdentity(target: Record<string, unknown>, name: string) {
248
+ const profile = typeof target.profile === "string" ? target.profile.trim() : "";
249
+ const bucket = normalizeRemoteKeySegment(
250
+ process.env.PI_SYNC_BUCKET ??
251
+ process.env.R2_BUCKET ??
252
+ (typeof target.bucket === "string" ? target.bucket : ""),
253
+ );
254
+ const prefix = normalizeRemoteKeySegment(
255
+ process.env.PI_SYNC_PREFIX ??
256
+ (typeof target.prefix === "string" ? target.prefix : DEFAULT_PREFIX),
257
+ );
258
+ const namespace = normalizeRemoteKeySegment(
259
+ process.env.PI_SYNC_PROFILE ?? (typeof target.namespace === "string" ? target.namespace : name),
260
+ );
261
+ return JSON.stringify([profile, bucket, prefix, namespace]);
262
+ }
263
+
264
+ function normalizeRemoteKeySegment(value: string) {
265
+ return trimSlashes(value.trim());
266
+ }
267
+
268
+ function requireNamedObjectMap(value: unknown, field: string): Record<string, unknown> {
269
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
270
+ throw new Error(`Invalid pi-sync settings: ${field} must be an object.`);
271
+ }
272
+ for (const name of Object.keys(value)) validateConfigName(name, field.slice(0, -1));
273
+ return value as Record<string, unknown>;
274
+ }
275
+
276
+ function ownObject(
277
+ value: Record<string, unknown>,
278
+ key: string,
279
+ ): Record<string, unknown> | undefined {
280
+ if (!Object.hasOwn(value, key)) return undefined;
281
+ const item = value[key];
282
+ return item && typeof item === "object" && !Array.isArray(item)
283
+ ? (item as Record<string, unknown>)
284
+ : undefined;
285
+ }
286
+
287
+ function validateConfigName(value: string, field: string) {
288
+ if (
289
+ !value.trim() ||
290
+ value.length > 100 ||
291
+ value === "__proto__" ||
292
+ value === "prototype" ||
293
+ value === "constructor" ||
294
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Config identifiers cannot render terminal controls.
295
+ /[\u0000-\u001f\u007f-\u009f]/u.test(value)
296
+ ) {
297
+ throw new Error(`Invalid pi-sync settings: invalid ${field} name.`);
298
+ }
299
+ }
300
+
301
+ function asOptionalString(value: unknown) {
302
+ if (value === undefined) return undefined;
303
+ if (typeof value !== "string") throw new Error("Invalid pi-sync settings: expected a string.");
304
+ return value;
305
+ }
306
+
307
+ function asOptionalBoolean(value: unknown) {
308
+ if (value === undefined) return undefined;
309
+ if (typeof value !== "boolean") throw new Error("Invalid pi-sync settings: expected a boolean.");
310
+ return value;
311
+ }
312
+
97
313
  export async function configuredSessionDir() {
98
314
  const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
99
315
  if (envSessionDir) return expandHome(envSessionDir);
100
- const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
316
+ const settings = await readJsonIfExists<{ sessionDir?: string }>(
317
+ path.join(agentDir(), "settings.json"),
318
+ );
101
319
  return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
102
320
  }
103
321
 
104
- export async function sessionDirForApply(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
322
+ export async function sessionDirForApply(
323
+ ctx: ExtensionCommandContext | ExtensionContext,
324
+ snapshot: Snapshot,
325
+ ) {
105
326
  const contextSessionDir = sessionDirFromContext(ctx);
106
327
  const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
107
328
  if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
108
329
 
109
330
  const localSessionDir = await configuredSessionDir();
110
- if (contextSessionDir && path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")) {
331
+ if (
332
+ contextSessionDir &&
333
+ path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")
334
+ ) {
111
335
  return contextSessionDir;
112
336
  }
113
337
  return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
@@ -140,15 +364,34 @@ export async function writeState(profile: string, state: SyncState) {
140
364
  await writeJson(statePath(profile), state);
141
365
  }
142
366
 
367
+ export async function readStateForConfig(config: SyncConfig): Promise<SyncState> {
368
+ if (config.settingsVersion !== 2) return readState(config.profile);
369
+ return (
370
+ (await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
371
+ version: VERSION,
372
+ profile: config.profile,
373
+ lastFileHashes: {},
374
+ }
375
+ );
376
+ }
377
+
378
+ export async function writeStateForConfig(config: SyncConfig, state: SyncState) {
379
+ await writeJson(statePathForConfig(config), state);
380
+ }
381
+
382
+ export function statePathForConfig(config: SyncConfig) {
383
+ if (config.settingsVersion !== 2) return statePath(config.profile);
384
+ const target = config.target ?? DEFAULT_PROFILE;
385
+ const hash = createHash("sha256").update(target).digest("hex").slice(0, 10);
386
+ return path.join(stateDir(), "targets", `${safeName(target)}-${hash}.state.json`);
387
+ }
388
+
143
389
  export function agentDir() {
144
- const configured = normalizeOptionalString(process.env.PI_CODING_AGENT_DIR);
145
- return configured ? expandHome(configured) : path.join(os.homedir(), ".pi", "agent");
390
+ return getAgentDir();
146
391
  }
147
392
 
148
393
  function expandHome(value: string) {
149
- return value === "~" || value.startsWith("~/")
150
- ? path.join(os.homedir(), value.slice(2))
151
- : value;
394
+ return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
152
395
  }
153
396
 
154
397
  export function stateDir() {
@@ -159,6 +402,106 @@ export function localConfigPath() {
159
402
  return path.join(agentDir(), "pi-sync.local.json");
160
403
  }
161
404
 
405
+ export function localConfigTemplate(): Record<string, unknown> {
406
+ return {
407
+ version: 2,
408
+ activeTarget: DEFAULT_PROFILE,
409
+ profiles: {
410
+ [DEFAULT_PROFILE]: {
411
+ kind: "r2",
412
+ endpoint: "https://<account-id>.r2.cloudflarestorage.com",
413
+ region: DEFAULT_REGION,
414
+ accessKeyId: "<access-key-id>",
415
+ secretAccessKey: "<secret-access-key>",
416
+ },
417
+ },
418
+ targets: {
419
+ [DEFAULT_PROFILE]: {
420
+ profile: DEFAULT_PROFILE,
421
+ bucket: "pi-sync",
422
+ namespace: DEFAULT_PROFILE,
423
+ prefix: DEFAULT_PREFIX,
424
+ autoSync: true,
425
+ syncFiles: [...DEFAULT_SYNC_FILES],
426
+ syncSessions: false,
427
+ extraFiles: [],
428
+ },
429
+ },
430
+ };
431
+ }
432
+
433
+ export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
434
+ const configPath = localConfigPath();
435
+ try {
436
+ const stat = await fs.lstat(configPath);
437
+ if (stat.isSymbolicLink())
438
+ throw new Error(`Refusing to read symlinked pi-sync config: ${configPath}`);
439
+ if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
440
+ } catch (error) {
441
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
442
+ throw error;
443
+ }
444
+ const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
445
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
446
+ throw new Error(`pi-sync config must contain a JSON object: ${configPath}`);
447
+ }
448
+ return parsed as Record<string, unknown>;
449
+ }
450
+
451
+ let configUpdateQueue: Promise<void> = Promise.resolve();
452
+
453
+ export function updateLocalConfig(
454
+ update: (current: Record<string, unknown>) => Record<string, unknown>,
455
+ ) {
456
+ const operation = configUpdateQueue.then(() => performLocalConfigUpdate(update));
457
+ configUpdateQueue = operation.then(
458
+ () => undefined,
459
+ () => undefined,
460
+ );
461
+ return operation;
462
+ }
463
+
464
+ async function performLocalConfigUpdate(
465
+ update: (current: Record<string, unknown>) => Record<string, unknown>,
466
+ ) {
467
+ const current = (await readLocalConfigObject()) ?? localConfigTemplate();
468
+ const next = update({ ...current });
469
+ await writeLocalConfigObject(next);
470
+ return next;
471
+ }
472
+
473
+ export async function writeLocalConfigObject(value: Record<string, unknown>) {
474
+ const configPath = localConfigPath();
475
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
476
+ try {
477
+ const stat = await fs.lstat(configPath);
478
+ if (stat.isSymbolicLink())
479
+ throw new Error(`Refusing to overwrite symlinked pi-sync config: ${configPath}`);
480
+ if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
481
+ } catch (error) {
482
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
483
+ }
484
+
485
+ const temporaryPath = path.join(
486
+ path.dirname(configPath),
487
+ `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
488
+ );
489
+ let handle: fs.FileHandle | undefined;
490
+ try {
491
+ handle = await fs.open(temporaryPath, "wx", 0o600);
492
+ await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
493
+ if (process.platform !== "win32") await handle.chmod(0o600);
494
+ await handle.sync();
495
+ await handle.close();
496
+ handle = undefined;
497
+ await fs.rename(temporaryPath, configPath);
498
+ } catch (error) {
499
+ await handle?.close().catch(() => undefined);
500
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
501
+ throw error;
502
+ }
503
+ }
504
+
162
505
  function statePath(profile: string) {
163
506
  return path.join(stateDir(), `${safeName(profile)}.state.json`);
164
507
  }
@@ -186,8 +529,12 @@ export async function writeJson(filePath: string, value: unknown) {
186
529
  }
187
530
 
188
531
  function selectSessionToken(fileSessionToken: string | undefined) {
189
- if (hasEnv("PI_SYNC_SESSION_TOKEN")) return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
190
- return normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ?? normalizeOptionalString(fileSessionToken);
532
+ if (hasEnv("PI_SYNC_SESSION_TOKEN"))
533
+ return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
534
+ return (
535
+ normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ??
536
+ normalizeOptionalString(fileSessionToken)
537
+ );
191
538
  }
192
539
 
193
540
  export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
@@ -204,19 +551,14 @@ export function syncSessionsWarnings(config: { syncSessions?: boolean }) {
204
551
  ];
205
552
  }
206
553
 
207
- function isSecurityTokenInvalidArgument(text: string) {
208
- return (
209
- text.includes("<Code>InvalidArgument</Code>") &&
210
- text.includes("<Message>X-Amz-Security-Token</Message>")
211
- );
212
- }
213
-
214
554
  export function isCloudflareR2Endpoint(endpoint: string | undefined) {
215
555
  const value = endpoint?.trim();
216
556
  if (!value) return false;
217
557
  try {
218
558
  const hostname = new URL(value).hostname.toLowerCase();
219
- return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
559
+ return (
560
+ hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
561
+ );
220
562
  } catch {
221
563
  return false;
222
564
  }
@@ -227,47 +569,15 @@ function normalizeOptionalString(value: string | undefined) {
227
569
  return normalized ? normalized : undefined;
228
570
  }
229
571
 
230
- export function normalizeExtraFiles(value: unknown) {
231
- if (!Array.isArray(value)) return [];
232
- const seen = new Set<string>();
233
- return value
234
- .filter((item): item is string => typeof item === "string")
235
- .map((item) => item.trim())
236
- .filter((item) => {
237
- const lower = item.toLowerCase();
238
- if (
239
- item === "" ||
240
- item === "." ||
241
- item === ".." ||
242
- item.includes("/") ||
243
- item.includes("\\") ||
244
- TOP_LEVEL_FILE_NAMES.has(lower) ||
245
- isDeniedPath(item) ||
246
- RESERVED_TOP_LEVEL_NAMES.has(lower) ||
247
- seen.has(lower)
248
- ) {
249
- return false;
250
- }
251
- seen.add(lower);
252
- return true;
253
- });
254
- }
255
-
256
- export function extraFilePathsByLower(value: unknown) {
257
- return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
258
- }
259
-
260
- function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
261
- const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
262
- if (exact) return exact;
263
- const lower = fileName.toLowerCase();
264
- return entries
265
- .filter((entry) => entry.isFile() && entry.name.toLowerCase() === lower)
266
- .sort((left, right) => left.name.localeCompare(right.name))[0];
572
+ function normalizeConfiguredString(value: string | undefined) {
573
+ const normalized = normalizeOptionalString(value);
574
+ return normalized && !/^<[^>]+>$/u.test(normalized) && !normalized.includes("<account-id>")
575
+ ? normalized
576
+ : undefined;
267
577
  }
268
578
 
269
579
  function hasEnv(name: string) {
270
- return Object.prototype.hasOwnProperty.call(process.env, name);
580
+ return Object.hasOwn(process.env, name);
271
581
  }
272
582
 
273
583
  export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
@@ -284,7 +594,3 @@ export function isExplicitlyEnabled(value: boolean | string | undefined) {
284
594
  export function isMissingConfigError(error: unknown) {
285
595
  return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
286
596
  }
287
-
288
- function errorMessage(error: unknown) {
289
- return error instanceof Error ? error.message : String(error);
290
- }