@narumitw/pi-sync 0.28.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/command.ts CHANGED
@@ -7,6 +7,7 @@ const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
7
7
  ];
8
8
  export const SYNC_COMMANDS = [
9
9
  { name: "help", description: "Show command usage" },
10
+ { name: "use", description: "Switch the current sync target", usageSuffix: " <target>" },
10
11
  { name: "init", description: "Create local config template" },
11
12
  { name: "config", description: "Show resolved configuration" },
12
13
  { name: "files", description: "Choose synced files" },
@@ -26,20 +27,39 @@ export type SyncCommandName = (typeof SYNC_COMMANDS)[number]["name"];
26
27
  const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = SYNC_COMMANDS.map(
27
28
  ({ name, description }) => ({ value: name, label: name, description }),
28
29
  );
30
+ let targetCompletionNames: string[] = [];
31
+
32
+ export function setSyncTargetCompletions(names: readonly string[]) {
33
+ targetCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
34
+ }
35
+ const TARGET_FLAG_COMPLETION = {
36
+ value: "--target",
37
+ label: "--target",
38
+ description: "Address a target without switching",
39
+ } as const;
29
40
  const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
41
+ config: [TARGET_FLAG_COMPLETION],
42
+ files: [TARGET_FLAG_COMPLETION],
43
+ status: [TARGET_FLAG_COMPLETION],
44
+ diff: [TARGET_FLAG_COMPLETION],
45
+ doctor: [TARGET_FLAG_COMPLETION],
30
46
  push: [
31
47
  ...YES_FLAG_COMPLETIONS,
32
48
  { value: "--force", label: "--force", description: "Overwrite visible remote changes" },
49
+ TARGET_FLAG_COMPLETION,
33
50
  ],
34
51
  pull: [
35
52
  ...YES_FLAG_COMPLETIONS,
36
53
  { value: "--force", label: "--force", description: "Overwrite local changes" },
54
+ TARGET_FLAG_COMPLETION,
37
55
  ],
38
56
  sync: [
39
57
  ...YES_FLAG_COMPLETIONS,
40
58
  { value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
59
+ TARGET_FLAG_COMPLETION,
41
60
  ],
42
- rollback: YES_FLAG_COMPLETIONS,
61
+ history: [TARGET_FLAG_COMPLETION],
62
+ rollback: [...YES_FLAG_COMPLETIONS, TARGET_FLAG_COMPLETION],
43
63
  unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
44
64
  };
45
65
 
@@ -51,17 +71,69 @@ export function splitArgs(input: string) {
51
71
  }
52
72
 
53
73
  export function parseOptions(args: string[]): CommandOptions {
74
+ const values: string[] = [];
75
+ let yes = false;
76
+ let force = false;
77
+ let stale = false;
78
+ let target: string | undefined;
79
+ for (let index = 0; index < args.length; index += 1) {
80
+ const arg = args[index];
81
+ if (arg === "--yes" || arg === "-y") yes = true;
82
+ else if (arg === "--force") force = true;
83
+ else if (arg === "--stale") stale = true;
84
+ else if (arg === "--target") {
85
+ const name = args[index + 1];
86
+ if (!name || name.startsWith("-")) throw new Error("--target requires a target name.");
87
+ if (target !== undefined) throw new Error("--target may be provided only once.");
88
+ target = name;
89
+ index += 1;
90
+ } else if (arg.startsWith("-")) {
91
+ throw new Error(`Unknown sync option: ${arg}`);
92
+ } else values.push(arg);
93
+ }
54
94
  return {
55
- yes: args.includes("--yes") || args.includes("-y"),
56
- force: args.includes("--force"),
57
- stale: args.includes("--stale"),
95
+ yes,
96
+ force,
97
+ stale,
58
98
  silent: false,
59
99
  reload: true,
60
100
  auto: false,
61
- args: args.filter((arg) => !arg.startsWith("-")),
101
+ ...(target === undefined ? {} : { target }),
102
+ args: values,
62
103
  };
63
104
  }
64
105
 
106
+ export function validateCommandOptions(command: string, options: CommandOptions) {
107
+ const targetAllowed = new Set([
108
+ "config",
109
+ "files",
110
+ "status",
111
+ "diff",
112
+ "doctor",
113
+ "push",
114
+ "pull",
115
+ "sync",
116
+ "history",
117
+ "rollback",
118
+ ]);
119
+ if (options.target && !targetAllowed.has(command)) {
120
+ throw new Error(`--target is not supported by /sync ${command}.`);
121
+ }
122
+ if ((options.yes || options.force) && !["push", "pull", "sync", "rollback"].includes(command)) {
123
+ throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
124
+ }
125
+ if (options.stale && command !== "unlock") {
126
+ throw new Error(`--stale is not supported by /sync ${command}.`);
127
+ }
128
+ const expectedValues = command === "rollback" || command === "use" ? 1 : 0;
129
+ if (options.args.length !== expectedValues) {
130
+ if (command === "rollback")
131
+ throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--target <name>]");
132
+ if (command === "use") throw new Error("Usage: /sync use <target>");
133
+ throw new Error(`Unexpected argument for /sync ${command}: ${options.args.join(" ")}`);
134
+ }
135
+ }
136
+
65
137
  export function completeSyncArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
66
138
  const prefix = argumentPrefix.trimStart();
67
139
  if (prefix === "") return [...SYNC_COMMAND_COMPLETIONS];
@@ -76,10 +148,20 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
76
148
  return matches.length > 0 ? [...matches] : null;
77
149
  }
78
150
 
151
+ const args = tokens.slice(1);
152
+ if (command === "use") {
153
+ if (args.length > 1 || (trailingSpace && args.length > 0)) return null;
154
+ return completeTargetValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
155
+ }
156
+ const targetFlagIndex = args.lastIndexOf("--target");
157
+ if (targetFlagIndex >= 0 && targetFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
158
+ const currentTarget = trailingSpace ? "" : (args.at(-1) ?? "");
159
+ if (!currentTarget.startsWith("-")) return completeTargetValue(prefix, currentTarget);
160
+ }
161
+
79
162
  const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
80
163
  if (!flagCompletions) return null;
81
164
 
82
- const args = tokens.slice(1);
83
165
  const completedArgs = trailingSpace ? args : args.slice(0, -1);
84
166
  const completedValues = completedArgs.filter((arg) => !arg.startsWith("-"));
85
167
  if (command === "rollback" ? completedValues.length > 1 : completedValues.length > 0) {
@@ -99,6 +181,19 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
99
181
  : null;
100
182
  }
101
183
 
184
+ function completeTargetValue(prefix: string, current: string) {
185
+ const currentRaw = current ? (prefix.match(/\S+$/u)?.[0] ?? "") : "";
186
+ const completionPrefix = currentRaw ? prefix.slice(0, prefix.length - currentRaw.length) : prefix;
187
+ const matches = targetCompletionNames.filter((name) => name.startsWith(current));
188
+ return matches.length > 0
189
+ ? matches.map((name) => ({
190
+ value: `${completionPrefix}${name}`,
191
+ label: name,
192
+ description: "Sync target",
193
+ }))
194
+ : null;
195
+ }
196
+
102
197
  export function syncMenuOptions() {
103
198
  return SYNC_COMMANDS.map(({ name, description }) => `${name} — ${description}`);
104
199
  }
@@ -135,6 +230,6 @@ export function usage() {
135
230
  return [
136
231
  "Usage: /sync <command>",
137
232
  `Commands: ${commands}`,
138
- "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit syncFiles/extraFiles in ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
233
+ "Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json). Existing PI_SYNC_* overrides still work but are deprecated for future major-version removal.",
139
234
  ].join("\n");
140
235
  }
package/src/config.ts CHANGED
@@ -1,8 +1,12 @@
1
- import { randomUUID } from "node:crypto";
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";
5
+ import {
6
+ type ExtensionCommandContext,
7
+ type ExtensionContext,
8
+ getAgentDir,
9
+ } from "@earendil-works/pi-coding-agent";
6
10
  import { safeName } from "./paths.js";
7
11
  import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
8
12
  import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
@@ -14,6 +18,19 @@ const DEFAULT_PROFILE = "default";
14
18
  const DEFAULT_PREFIX = "pi-sync";
15
19
  const DEFAULT_REGION = "auto";
16
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;
33
+
17
34
  function trimSlashes(value: string) {
18
35
  return value.replace(/^\/+|\/+$/g, "");
19
36
  }
@@ -39,12 +56,12 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
39
56
  : undefined;
40
57
  }
41
58
 
42
- export async function loadConfigInternal(): Promise<SyncConfig> {
43
- const partial = await loadPartialConfig();
44
- const endpoint = partial.endpoint;
45
- const bucket = partial.bucket;
46
- const accessKeyId = partial.accessKeyId;
47
- 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);
48
65
  const missing = [
49
66
  ["endpoint", endpoint],
50
67
  ["bucket", bucket],
@@ -55,7 +72,7 @@ export async function loadConfigInternal(): Promise<SyncConfig> {
55
72
  .map(([name]) => name);
56
73
  if (missing.length > 0) {
57
74
  throw new Error(
58
- `Missing pi-sync config: ${missing.join(", ")}. Run /sync init or set PI_SYNC_* environment variables.`,
75
+ `Missing pi-sync config: ${missing.join(", ")}. Use /sync setup or edit ${localConfigPath()}.`,
59
76
  );
60
77
  }
61
78
  if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
@@ -65,26 +82,63 @@ export async function loadConfigInternal(): Promise<SyncConfig> {
65
82
  return {
66
83
  endpoint,
67
84
  bucket,
68
- region: partial.region ?? DEFAULT_REGION,
85
+ region: normalizeOptionalString(partial.region) ?? DEFAULT_REGION,
69
86
  accessKeyId,
70
87
  secretAccessKey,
71
- sessionToken: partial.sessionToken,
72
- profile: partial.profile ?? DEFAULT_PROFILE,
73
- prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
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,
74
96
  syncFiles: normalizeSyncFiles(partial.syncFiles),
75
97
  syncSessions: isExplicitlyEnabled(partial.syncSessions),
76
98
  extraFiles: normalizeExtraFiles(partial.extraFiles),
77
99
  };
78
100
  }
79
101
 
80
- export async function loadConfig(): Promise<SyncConfig> {
81
- 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);
82
122
  }
83
123
 
84
- export async function loadPartialConfig(): Promise<PartialConfig> {
85
- const fileConfig = ((await readLocalConfigObject()) ?? {}) as PartialConfig;
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
+ ];
134
+ }
135
+
136
+ function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
86
137
  return {
87
138
  ...fileConfig,
139
+ target: DEFAULT_PROFILE,
140
+ storageProfile: DEFAULT_PROFILE,
141
+ settingsVersion: 1,
88
142
  endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
89
143
  bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
90
144
  region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
@@ -98,12 +152,164 @@ export async function loadPartialConfig(): Promise<PartialConfig> {
98
152
  profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
99
153
  prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
100
154
  autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
101
- syncFiles: fileConfig.syncFiles,
102
155
  syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
103
- extraFiles: fileConfig.extraFiles,
104
156
  };
105
157
  }
106
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
+
107
313
  export async function configuredSessionDir() {
108
314
  const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
109
315
  if (envSessionDir) return expandHome(envSessionDir);
@@ -158,9 +364,30 @@ export async function writeState(profile: string, state: SyncState) {
158
364
  await writeJson(statePath(profile), state);
159
365
  }
160
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
+
161
389
  export function agentDir() {
162
- const configured = normalizeOptionalString(process.env.PI_CODING_AGENT_DIR);
163
- return configured ? expandHome(configured) : path.join(os.homedir(), ".pi", "agent");
390
+ return getAgentDir();
164
391
  }
165
392
 
166
393
  function expandHome(value: string) {
@@ -177,17 +404,29 @@ export function localConfigPath() {
177
404
 
178
405
  export function localConfigTemplate(): Record<string, unknown> {
179
406
  return {
180
- endpoint: "https://<account-id>.r2.cloudflarestorage.com",
181
- bucket: "pi-sync",
182
- region: DEFAULT_REGION,
183
- accessKeyId: "<access-key-id>",
184
- secretAccessKey: "<secret-access-key>",
185
- profile: DEFAULT_PROFILE,
186
- prefix: DEFAULT_PREFIX,
187
- autoSync: true,
188
- syncFiles: [...DEFAULT_SYNC_FILES],
189
- syncSessions: false,
190
- extraFiles: [],
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
+ },
191
430
  };
192
431
  }
193
432
 
@@ -209,7 +448,20 @@ export async function readLocalConfigObject(): Promise<Record<string, unknown> |
209
448
  return parsed as Record<string, unknown>;
210
449
  }
211
450
 
212
- export async function updateLocalConfig(
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(
213
465
  update: (current: Record<string, unknown>) => Record<string, unknown>,
214
466
  ) {
215
467
  const current = (await readLocalConfigObject()) ?? localConfigTemplate();
@@ -317,6 +569,13 @@ function normalizeOptionalString(value: string | undefined) {
317
569
  return normalized ? normalized : undefined;
318
570
  }
319
571
 
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;
577
+ }
578
+
320
579
  function hasEnv(name: string) {
321
580
  return Object.hasOwn(process.env, name);
322
581
  }