@narumitw/pi-sync 0.29.2 → 0.31.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/README.md CHANGED
@@ -79,30 +79,42 @@ A configured manager shows:
79
79
  ```text
80
80
  Current target: home
81
81
  Storage: Cloudflare R2 · personal-pi
82
- Auto-sync: On · Sessions: Off
83
- Last known sync: Remote not checked
82
+ Synced content: 9 built-in groups · 0 extra files · Sessions: Off
83
+ Auto-sync: On
84
+ Last applied snapshot: snapshot-id (or Never synced)
85
+ Remote changes: Not checked
84
86
  ```
85
87
 
86
88
  Its primary actions are:
87
89
 
88
- - **Sync now** — conservatively decide whether to push, pull, or do nothing
90
+ - **Sync now (recommended)** — conservatively decide whether to push, pull, or do nothing
91
+ - **Pull from remote** — check the remote snapshot, preview exact local writes/deletes, then confirm
92
+ - **Push to remote** — scan and preview exact remote publication changes, then confirm
89
93
  - **Switch target** — preview the target change, then follow the configured pull behavior
90
94
  - **Status & changes** — perform a cancellable read-only remote check
91
95
  - **Settings** — control post-switch pulling, automatic sync, and synced content
92
- - **Manage targets & storage** — add, edit, or remove local target/profile definitions
93
- - **History & recovery** — browse snapshots, preview rollback, diagnose setup, or recover a stale lock
94
- - **Help**
96
+ - **More…** — open one shallow level containing target/storage management, history/recovery, Help, and Back
95
97
 
96
- Escape exits the main menu. Secondary menus provide Back; dirty synced-content drafts provide Save, Discard, and Continue editing. Cancellation before publication has no side effects.
98
+ The menu does not query remote storage merely to open. It shows the last locally applied snapshot and marks remote changes as unchecked until an operation runs. When no synced content is selected, transfer actions are hidden and Settings becomes the first action. During an active or recoverable lock, mutation actions remain unavailable.
99
+
100
+ Push and pull keep their existing concrete previews and confirmation prompts. Pull creates a local backup before applying; push scans locally managed content for likely secrets before publication. Escape cancels a pre-commit check without changing local or remote files. Once publication or apply begins, cancellation is disabled. Conflicts never force automatically; inspect **Status & changes** and use an explicit direct route only after reviewing the conflict.
101
+
102
+ Escape exits the main menu. **More…** and secondary menus provide Back; dirty synced-content drafts provide Save, Discard, and Continue editing. Cancellation before publication has no side effects.
97
103
 
98
104
  ## ⚙️ Settings
99
105
 
100
106
  The private user settings file is:
101
107
 
102
108
  ```text
103
- ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-sync.local.json
109
+ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-sync.json
104
110
  ```
105
111
 
112
+ On first read, an existing `pi-sync.local.json` is migrated byte-for-byte to `pi-sync.json`
113
+ with private POSIX `0600` permissions. The private legacy file is retained as a recovery copy and may
114
+ be deleted after verifying the new file. If both files exist, `pi-sync.json` takes precedence and the
115
+ legacy file remains untouched. Malformed, invalid, symlinked, changed, or otherwise unsafe legacy
116
+ files are never overwritten automatically.
117
+
106
118
  A version 2 example:
107
119
 
108
120
  ```json
@@ -208,7 +220,7 @@ Environment-overridden fields are read-only in interactive settings. Cloudflare
208
220
 
209
221
  ### Legacy flat settings
210
222
 
211
- Existing flat `pi-sync.local.json` files continue to work unchanged as a synthetic `default` target/profile. The old flat `profile` remains the remote namespace.
223
+ Existing flat settings continue to work unchanged as a synthetic `default` target/profile, including settings migrated from the legacy `pi-sync.local.json` filename. The old flat `profile` remains the remote namespace.
212
224
 
213
225
  Adding a second target offers an explicit migration preview. On confirmation pi-sync:
214
226
 
@@ -292,7 +304,7 @@ Sessions can contain prompts, model output, tool results, file paths, images, an
292
304
 
293
305
  ## 🛡️ Safety notes
294
306
 
295
- - Credentials stay local and are never included in snapshots.
307
+ - Credentials stay local; canonical, legacy, temporary, and migration-recovery settings files are always excluded from snapshots.
296
308
  - Push refuses common secret patterns in locally managed files.
297
309
  - Pull/rollback reject unsafe paths, duplicate paths, checksum mismatches, symlink parents, and file/directory replacement hazards before mutation.
298
310
  - Unmanaged local/remote files remain preserved.
@@ -320,6 +332,7 @@ extensions/pi-sync/
320
332
  │ ├── sync.ts # Command and lifecycle orchestration
321
333
  │ ├── manager-ui.ts # Goal-oriented menus and setup/management flows
322
334
  │ ├── file-selection.ts # Transactional synced-content editor
335
+ │ ├── config-file.ts # Private settings I/O and legacy filename migration
323
336
  │ ├── settings-management.ts # Profiles, targets, migration, and atomic saves
324
337
  │ ├── settings-ui.ts # SettingsList interaction and serialized saves
325
338
  │ ├── target-switch.ts # Post-switch prompt, policy, and target handoff
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.29.2",
3
+ "version": "0.31.0",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,9 +32,9 @@
32
32
  "proper-lockfile": "^4.1.2"
33
33
  },
34
34
  "devDependencies": {
35
- "@biomejs/biome": "2.5.3",
36
- "@earendil-works/pi-coding-agent": "0.80.10",
37
- "@earendil-works/pi-tui": "0.80.10",
35
+ "@biomejs/biome": "2.5.5",
36
+ "@earendil-works/pi-coding-agent": "0.82.1",
37
+ "@earendil-works/pi-tui": "0.82.1",
38
38
  "@types/proper-lockfile": "^4.1.4",
39
39
  "typescript": "7.0.2"
40
40
  },
package/src/command.ts CHANGED
@@ -230,6 +230,6 @@ export function usage() {
230
230
  return [
231
231
  "Usage: /sync <command>",
232
232
  `Commands: ${commands}`,
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.",
233
+ "Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.json (or $PI_CODING_AGENT_DIR/pi-sync.json). Existing PI_SYNC_* overrides still work but are deprecated for future major-version removal.",
234
234
  ].join("\n");
235
235
  }
@@ -0,0 +1,545 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ constants as fsConstants,
4
+ mkdir,
5
+ mkdirSync,
6
+ realpath,
7
+ realpathSync,
8
+ rmdir,
9
+ rmdirSync,
10
+ stat,
11
+ statSync,
12
+ utimes,
13
+ utimesSync,
14
+ } from "node:fs";
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import lockfile from "proper-lockfile";
19
+
20
+ const CONFIG_FILE_NAME = "pi-sync.json";
21
+ const LEGACY_CONFIG_FILE_NAME = "pi-sync.local.json";
22
+ const configMigrationNotices = new Map<string, string>();
23
+ const legacyPresenceNoticed = new Set<string>();
24
+ const CONFIG_LOCK_STALE_MS = 30_000;
25
+ const CONFIG_LOCK_UPDATE_MS = 10_000;
26
+ const LOCKFILE_FS_ADAPTER = {
27
+ mkdir,
28
+ mkdirSync,
29
+ realpath,
30
+ realpathSync,
31
+ rmdir,
32
+ rmdirSync,
33
+ stat,
34
+ statSync,
35
+ utimes,
36
+ utimesSync,
37
+ };
38
+
39
+ type LinkFile = (source: string, destination: string) => Promise<void>;
40
+ let linkConfigFile: LinkFile = (source, destination) => fs.link(source, destination);
41
+ let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
42
+ let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
43
+
44
+ export type FileIdentity = { dev: number; ino: number };
45
+
46
+ export async function withConfigFileLinkForTest<T>(
47
+ link: LinkFile,
48
+ run: () => Promise<T>,
49
+ ): Promise<T> {
50
+ const previous = linkConfigFile;
51
+ linkConfigFile = link;
52
+ try {
53
+ return await run();
54
+ } finally {
55
+ linkConfigFile = previous;
56
+ }
57
+ }
58
+
59
+ export async function withConfigReplacementInstalledHookForTest<T>(
60
+ hook: () => Promise<void>,
61
+ run: () => Promise<T>,
62
+ ): Promise<T> {
63
+ const previous = afterReplacementInstalledHook;
64
+ afterReplacementInstalledHook = hook;
65
+ try {
66
+ return await run();
67
+ } finally {
68
+ afterReplacementInstalledHook = previous;
69
+ }
70
+ }
71
+
72
+ export async function withConfigQuarantinedHookForTest<T>(
73
+ hook: () => Promise<void>,
74
+ run: () => Promise<T>,
75
+ ): Promise<T> {
76
+ const previous = afterConfigQuarantinedHook;
77
+ afterConfigQuarantinedHook = hook;
78
+ try {
79
+ return await run();
80
+ } finally {
81
+ afterConfigQuarantinedHook = previous;
82
+ }
83
+ }
84
+
85
+ type ConfigSnapshot = {
86
+ bytes: Buffer;
87
+ identity: FileIdentity;
88
+ parsed: Record<string, unknown>;
89
+ };
90
+
91
+ export type LocalConfigDocument = ConfigSnapshot & { path: string };
92
+
93
+ export function localConfigPath() {
94
+ return path.join(getAgentDir(), CONFIG_FILE_NAME);
95
+ }
96
+
97
+ export function legacyLocalConfigPath() {
98
+ return path.join(getAgentDir(), LEGACY_CONFIG_FILE_NAME);
99
+ }
100
+
101
+ export async function activeLocalConfigPath() {
102
+ return withLocalConfigFileLock(async () => {
103
+ const canonicalPath = localConfigPath();
104
+ if (await pathExists(canonicalPath)) return canonicalPath;
105
+ const legacyPath = legacyLocalConfigPath();
106
+ return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
107
+ });
108
+ }
109
+
110
+ export function consumeLocalConfigMigrationNotice() {
111
+ const configPath = localConfigPath();
112
+ const notice = configMigrationNotices.get(configPath);
113
+ configMigrationNotices.delete(configPath);
114
+ return notice;
115
+ }
116
+
117
+ export async function readMigratingLocalConfigDocument(
118
+ validateForMigration: (settings: Record<string, unknown>) => void,
119
+ ): Promise<LocalConfigDocument | undefined> {
120
+ return withLocalConfigFileLock(async () => {
121
+ const configPath = await prepareLocalConfigPath(validateForMigration);
122
+ const snapshot = await readConfigSnapshotIfExists(configPath);
123
+ return snapshot ? { path: configPath, ...snapshot } : undefined;
124
+ });
125
+ }
126
+
127
+ export function replaceLocalConfigDocument(
128
+ document: LocalConfigDocument,
129
+ value: Record<string, unknown>,
130
+ ) {
131
+ return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
132
+ }
133
+
134
+ async function replaceLocalConfigDocumentUnlocked(
135
+ document: LocalConfigDocument,
136
+ value: Record<string, unknown>,
137
+ ) {
138
+ const nextBytes = Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
139
+ const canonicalPath = localConfigPath();
140
+ if (document.path !== canonicalPath) {
141
+ if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
142
+ let installed: FileIdentity;
143
+ try {
144
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
145
+ } catch (error) {
146
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
147
+ throw new Error("Canonical settings were created concurrently; no settings were replaced.");
148
+ }
149
+ throw error;
150
+ }
151
+ if (!(await configDocumentStillMatches(document))) {
152
+ await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
153
+ throw settingsChangedError();
154
+ }
155
+ return;
156
+ }
157
+
158
+ const quarantinePath = await claimCanonicalConfigDocument(document);
159
+ let installed: FileIdentity;
160
+ try {
161
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
162
+ } catch (error) {
163
+ await restoreQuarantinedConfig(canonicalPath, quarantinePath);
164
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
165
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
166
+ }
167
+ throw error;
168
+ }
169
+ await afterReplacementInstalledHook();
170
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
171
+ await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
172
+ await restoreQuarantinedConfig(canonicalPath, quarantinePath);
173
+ throw settingsChangedError();
174
+ }
175
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
176
+ await syncParentDirectory(canonicalPath).catch(() => undefined);
177
+ }
178
+
179
+ async function prepareLocalConfigPath(
180
+ validateForMigration: (settings: Record<string, unknown>) => void,
181
+ ) {
182
+ const canonicalPath = localConfigPath();
183
+ const legacyPath = legacyLocalConfigPath();
184
+ if (await pathExists(canonicalPath)) {
185
+ const legacyStatus = await secureIgnoredLegacyIfPresent(legacyPath);
186
+ if (legacyStatus !== "missing" && !legacyPresenceNoticed.has(canonicalPath)) {
187
+ legacyPresenceNoticed.add(canonicalPath);
188
+ recordConfigMigrationNotice(
189
+ canonicalPath,
190
+ legacyStatus === "private"
191
+ ? `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence. Delete ${LEGACY_CONFIG_FILE_NAME} after confirming your settings.`
192
+ : `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence, but pi-sync could not verify them as a private regular file. Secure or delete the legacy path after confirming your settings.`,
193
+ );
194
+ }
195
+ return canonicalPath;
196
+ }
197
+
198
+ const legacy = await readConfigSnapshotIfExists(legacyPath);
199
+ if (!legacy) return canonicalPath;
200
+ validateForMigration(legacy.parsed);
201
+
202
+ let installedIdentity: FileIdentity;
203
+ try {
204
+ installedIdentity = await installPrivateConfigExclusively(canonicalPath, legacy.bytes);
205
+ } catch (error) {
206
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
207
+ recordConfigMigrationNotice(
208
+ canonicalPath,
209
+ `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} was created concurrently and takes precedence.`,
210
+ );
211
+ return canonicalPath;
212
+ }
213
+ recordConfigMigrationNotice(
214
+ canonicalPath,
215
+ `Could not migrate ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the legacy settings were used for this session and were not changed.`,
216
+ );
217
+ return legacyPath;
218
+ }
219
+
220
+ if (!(await configSnapshotStillMatches(legacyPath, legacy))) {
221
+ const removed = await quarantineAndRemoveConfigIfMatchesUnlocked(
222
+ canonicalPath,
223
+ installedIdentity,
224
+ legacy.bytes,
225
+ );
226
+ recordConfigMigrationNotice(
227
+ canonicalPath,
228
+ removed
229
+ ? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
230
+ : `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
231
+ );
232
+ return removed ? legacyPath : canonicalPath;
233
+ }
234
+
235
+ legacyPresenceNoticed.add(canonicalPath);
236
+ recordConfigMigrationNotice(
237
+ canonicalPath,
238
+ `pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
239
+ );
240
+ return canonicalPath;
241
+ }
242
+
243
+ async function secureIgnoredLegacyIfPresent(filePath: string) {
244
+ let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
245
+ try {
246
+ pathStat = await fs.lstat(filePath);
247
+ } catch (error) {
248
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing" as const;
249
+ return "unsafe" as const;
250
+ }
251
+ if (pathStat.isSymbolicLink() || !pathStat.isFile()) return "unsafe" as const;
252
+
253
+ let handle: fs.FileHandle | undefined;
254
+ try {
255
+ handle = await fs.open(filePath, "r");
256
+ const openedStat = await handle.stat();
257
+ if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
258
+ return "unsafe" as const;
259
+ }
260
+ if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
261
+ await handle.chmod(0o600);
262
+ }
263
+ return "private" as const;
264
+ } catch {
265
+ return "unsafe" as const;
266
+ } finally {
267
+ await handle?.close().catch(() => undefined);
268
+ }
269
+ }
270
+
271
+ async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnapshot | undefined> {
272
+ let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
273
+ try {
274
+ pathStat = await fs.lstat(filePath);
275
+ } catch (error) {
276
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
277
+ throw error;
278
+ }
279
+ if (pathStat.isSymbolicLink()) {
280
+ throw new Error(`Refusing to read symlinked pi-sync config: ${filePath}`);
281
+ }
282
+ if (!pathStat.isFile()) throw new Error(`pi-sync config is not a regular file: ${filePath}`);
283
+
284
+ const handle = await fs.open(filePath, "r");
285
+ try {
286
+ const openedStat = await handle.stat();
287
+ if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
288
+ throw new Error(`pi-sync config changed while opening: ${filePath}`);
289
+ }
290
+ if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
291
+ await handle.chmod(0o600);
292
+ }
293
+ const bytes = await handle.readFile();
294
+ return {
295
+ bytes,
296
+ identity: { dev: openedStat.dev, ino: openedStat.ino },
297
+ parsed: parseConfigObject(bytes, filePath),
298
+ };
299
+ } finally {
300
+ await handle.close();
301
+ }
302
+ }
303
+
304
+ function parseConfigObject(bytes: Buffer, filePath: string) {
305
+ let parsed: unknown;
306
+ try {
307
+ parsed = JSON.parse(bytes.toString("utf8"));
308
+ } catch {
309
+ throw new SyntaxError(`Invalid JSON in pi-sync config: ${filePath}`);
310
+ }
311
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
312
+ throw new Error(`pi-sync config must contain a JSON object: ${filePath}`);
313
+ }
314
+ return parsed as Record<string, unknown>;
315
+ }
316
+
317
+ async function installPrivateConfigExclusively(
318
+ filePath: string,
319
+ bytes: Buffer,
320
+ allowCopyFallback = false,
321
+ ) {
322
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
323
+ const temporaryPath = path.join(
324
+ path.dirname(filePath),
325
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
326
+ );
327
+ let handle: fs.FileHandle | undefined;
328
+ try {
329
+ handle = await fs.open(temporaryPath, "wx", 0o600);
330
+ await handle.writeFile(bytes);
331
+ if (process.platform !== "win32") await handle.chmod(0o600);
332
+ await handle.sync();
333
+ await handle.close();
334
+ handle = undefined;
335
+ try {
336
+ await linkConfigFile(temporaryPath, filePath);
337
+ } catch (error) {
338
+ if (!allowCopyFallback || !isUnsupportedLinkError(error)) throw error;
339
+ await fs.copyFile(temporaryPath, filePath, fsConstants.COPYFILE_EXCL);
340
+ }
341
+ const installed = await fs.lstat(filePath);
342
+ try {
343
+ if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
344
+ await syncParentDirectory(filePath).catch(() => undefined);
345
+ } catch (error) {
346
+ await quarantineAndRemoveConfigIfMatchesUnlocked(
347
+ filePath,
348
+ { dev: installed.dev, ino: installed.ino },
349
+ bytes,
350
+ );
351
+ throw error;
352
+ }
353
+ return { dev: installed.dev, ino: installed.ino };
354
+ } finally {
355
+ await handle?.close().catch(() => undefined);
356
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
357
+ }
358
+ }
359
+
360
+ async function configDocumentStillMatches(document: LocalConfigDocument) {
361
+ return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
362
+ }
363
+
364
+ async function claimCanonicalConfigDocument(document: LocalConfigDocument) {
365
+ const quarantinePath = path.join(
366
+ path.dirname(document.path),
367
+ `.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
368
+ );
369
+ try {
370
+ await fs.rename(document.path, quarantinePath);
371
+ await syncParentDirectory(document.path);
372
+ } catch (error) {
373
+ await restoreQuarantinedConfig(document.path, quarantinePath);
374
+ throw error;
375
+ }
376
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
377
+ await restoreQuarantinedConfig(document.path, quarantinePath);
378
+ throw settingsChangedError();
379
+ }
380
+ if (await pathExists(document.path)) {
381
+ await restoreQuarantinedConfig(document.path, quarantinePath);
382
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
383
+ }
384
+ return quarantinePath;
385
+ }
386
+
387
+ function settingsChangedError() {
388
+ return new Error("pi-sync settings changed during migration; no settings were replaced.");
389
+ }
390
+
391
+ function isUnsupportedLinkError(error: unknown) {
392
+ const code = (error as NodeJS.ErrnoException).code;
393
+ return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM";
394
+ }
395
+
396
+ async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
397
+ return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
398
+ }
399
+
400
+ export function quarantineAndRemoveConfigIfMatches(
401
+ filePath: string,
402
+ identity: FileIdentity,
403
+ expectedBytes: Buffer,
404
+ ) {
405
+ return withLocalConfigFileLock(() =>
406
+ quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes),
407
+ );
408
+ }
409
+
410
+ async function quarantineAndRemoveConfigIfMatchesUnlocked(
411
+ filePath: string,
412
+ identity: FileIdentity,
413
+ expectedBytes: Buffer,
414
+ ) {
415
+ const quarantinePath = path.join(
416
+ path.dirname(filePath),
417
+ `.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
418
+ );
419
+ try {
420
+ await fs.rename(filePath, quarantinePath);
421
+ } catch {
422
+ return false;
423
+ }
424
+ await afterConfigQuarantinedHook();
425
+ try {
426
+ await syncParentDirectory(filePath);
427
+ } catch {
428
+ await restoreQuarantinedConfig(filePath, quarantinePath);
429
+ return false;
430
+ }
431
+
432
+ const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
433
+ if (!matches) {
434
+ await restoreQuarantinedConfig(filePath, quarantinePath);
435
+ return false;
436
+ }
437
+ if (await pathExists(filePath)) {
438
+ await fs.rm(quarantinePath, { force: true });
439
+ await syncParentDirectory(filePath);
440
+ return false;
441
+ }
442
+ await fs.rm(quarantinePath);
443
+ await syncParentDirectory(filePath);
444
+ return true;
445
+ }
446
+
447
+ async function fileIdentityAndContentsMatch(
448
+ filePath: string,
449
+ identity: FileIdentity,
450
+ expectedBytes: Buffer,
451
+ ) {
452
+ try {
453
+ const current = await fs.lstat(filePath);
454
+ if (current.isSymbolicLink()) return false;
455
+ if (current.dev !== identity.dev || current.ino !== identity.ino) return false;
456
+ return (await fs.readFile(filePath)).equals(expectedBytes);
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
462
+ async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
463
+ try {
464
+ await linkConfigFile(quarantinePath, filePath);
465
+ await fs.rm(quarantinePath);
466
+ await syncParentDirectory(filePath);
467
+ return;
468
+ } catch (error) {
469
+ if (isUnsupportedLinkError(error)) {
470
+ try {
471
+ await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
472
+ if (process.platform !== "win32") {
473
+ await fs.chmod(filePath, 0o600);
474
+ await fs.chmod(quarantinePath, 0o600);
475
+ }
476
+ await syncParentDirectory(filePath);
477
+ return;
478
+ } catch (copyError) {
479
+ if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
480
+ }
481
+ } else if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
482
+ return;
483
+ }
484
+ }
485
+ try {
486
+ const [current, quarantined] = await Promise.all([
487
+ fs.readFile(filePath),
488
+ fs.readFile(quarantinePath),
489
+ ]);
490
+ if (current.equals(quarantined)) await fs.rm(quarantinePath);
491
+ else if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
492
+ await syncParentDirectory(filePath);
493
+ } catch {
494
+ // Preserve the quarantine rather than risk deleting settings that changed concurrently.
495
+ }
496
+ }
497
+
498
+ export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
499
+ const configPath = localConfigPath();
500
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
501
+ let compromisedError: Error | undefined;
502
+ const release = await lockfile.lock(configPath, {
503
+ fs: LOCKFILE_FS_ADAPTER,
504
+ lockfilePath: `${configPath}.mutation-lock`,
505
+ realpath: false,
506
+ stale: CONFIG_LOCK_STALE_MS,
507
+ update: CONFIG_LOCK_UPDATE_MS,
508
+ retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
509
+ onCompromised: (error) => {
510
+ compromisedError = error;
511
+ },
512
+ });
513
+ try {
514
+ const result = await run();
515
+ if (compromisedError) throw compromisedError;
516
+ return result;
517
+ } finally {
518
+ await release();
519
+ }
520
+ }
521
+
522
+ async function syncParentDirectory(filePath: string) {
523
+ if (process.platform === "win32") return;
524
+ let handle: fs.FileHandle | undefined;
525
+ try {
526
+ handle = await fs.open(path.dirname(filePath), "r");
527
+ await handle.sync();
528
+ } finally {
529
+ await handle?.close().catch(() => undefined);
530
+ }
531
+ }
532
+
533
+ async function pathExists(filePath: string) {
534
+ try {
535
+ await fs.lstat(filePath);
536
+ return true;
537
+ } catch (error) {
538
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
539
+ throw error;
540
+ }
541
+ }
542
+
543
+ function recordConfigMigrationNotice(configPath: string, notice: string) {
544
+ if (!configMigrationNotices.has(configPath)) configMigrationNotices.set(configPath, notice);
545
+ }
package/src/config.ts CHANGED
@@ -7,6 +7,16 @@ import {
7
7
  type ExtensionContext,
8
8
  getAgentDir,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ activeLocalConfigPath,
12
+ consumeLocalConfigMigrationNotice,
13
+ legacyLocalConfigPath,
14
+ localConfigPath,
15
+ quarantineAndRemoveConfigIfMatches,
16
+ readMigratingLocalConfigDocument,
17
+ replaceLocalConfigDocument,
18
+ withLocalConfigFileLock,
19
+ } from "./config-file.js";
10
20
  import { safeName } from "./paths.js";
11
21
  import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
12
22
  import type {
@@ -25,6 +35,15 @@ const DEFAULT_PREFIX = "pi-sync";
25
35
  const DEFAULT_REGION = "auto";
26
36
  export const DEFAULT_TARGET_SWITCH_ACTION: TargetSwitchAction = "ask";
27
37
 
38
+ export {
39
+ activeLocalConfigPath,
40
+ consumeLocalConfigMigrationNotice,
41
+ legacyLocalConfigPath,
42
+ localConfigPath,
43
+ quarantineAndRemoveConfigIfMatches,
44
+ replaceLocalConfigDocument,
45
+ };
46
+
28
47
  export const DEPRECATED_PI_SYNC_ENV_NAMES = [
29
48
  "PI_SYNC_ENDPOINT",
30
49
  "PI_SYNC_BUCKET",
@@ -491,10 +510,6 @@ export function stateDir() {
491
510
  return path.join(agentDir(), ".pisync");
492
511
  }
493
512
 
494
- export function localConfigPath() {
495
- return path.join(agentDir(), "pi-sync.local.json");
496
- }
497
-
498
513
  export function localConfigTemplate(): Record<string, unknown> {
499
514
  return {
500
515
  version: 2,
@@ -524,22 +539,91 @@ export function localConfigTemplate(): Record<string, unknown> {
524
539
  };
525
540
  }
526
541
 
542
+ export async function readLocalConfigDocument() {
543
+ return readMigratingLocalConfigDocument(validateConfigDocumentForMigration);
544
+ }
545
+
527
546
  export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
528
- const configPath = localConfigPath();
529
- try {
530
- const stat = await fs.lstat(configPath);
531
- if (stat.isSymbolicLink())
532
- throw new Error(`Refusing to read symlinked pi-sync config: ${configPath}`);
533
- if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
534
- } catch (error) {
535
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
536
- throw error;
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
+ }
537
599
  }
538
- const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
539
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
540
- throw new Error(`pi-sync config must contain a JSON object: ${configPath}`);
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.");
541
626
  }
542
- return parsed as Record<string, unknown>;
543
627
  }
544
628
 
545
629
  let configUpdateQueue: Promise<void> = Promise.resolve();
@@ -564,7 +648,11 @@ async function performLocalConfigUpdate(
564
648
  return next;
565
649
  }
566
650
 
567
- export async function writeLocalConfigObject(value: Record<string, unknown>) {
651
+ export function writeLocalConfigObject(value: Record<string, unknown>) {
652
+ return withLocalConfigFileLock(() => writeLocalConfigObjectUnlocked(value));
653
+ }
654
+
655
+ async function writeLocalConfigObjectUnlocked(value: Record<string, unknown>) {
568
656
  const configPath = localConfigPath();
569
657
  await fs.mkdir(path.dirname(configPath), { recursive: true });
570
658
  try {
package/src/manager-ui.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { setSyncTargetCompletions } from "./command.js";
3
3
  import {
4
+ activeLocalConfigPath,
4
5
  configuredTargetNames,
5
6
  deprecatedPiSyncEnvironmentWarnings,
6
7
  isCloudflareR2Endpoint,
@@ -10,6 +11,7 @@ import {
10
11
  localConfigPath,
11
12
  normalizeSyncFiles,
12
13
  readLocalConfigObject,
14
+ readStateForConfig,
13
15
  } from "./config.js";
14
16
  import { inspectLock, isStaleLock } from "./lock.js";
15
17
  import {
@@ -28,18 +30,25 @@ import { type TargetPullOutcome, useSyncTarget } from "./target-switch.js";
28
30
  import type { SyncConfig } from "./types.js";
29
31
 
30
32
  export const MAIN_MENU_ACTIONS = [
31
- "Sync now",
33
+ "Sync now (recommended)",
34
+ "Pull from remote",
35
+ "Push to remote",
32
36
  "Switch target",
33
37
  "Status & changes",
34
38
  "Settings",
39
+ "More…",
40
+ ] as const;
41
+
42
+ const MORE_MENU_ACTIONS = [
35
43
  "Manage targets & storage",
36
44
  "History & recovery",
37
45
  "Help",
46
+ "Back",
38
47
  ] as const;
39
-
40
48
  const BACK = "Back";
41
49
 
42
50
  type MainMenuAction = (typeof MAIN_MENU_ACTIONS)[number];
51
+ type ContextualMenuAction = "Manage targets & storage" | "History & recovery" | "Help";
43
52
  type RunRoute = (
44
53
  route: string,
45
54
  signal?: AbortSignal,
@@ -59,10 +68,34 @@ export async function showSyncManager(
59
68
  const state = await describeManagerState();
60
69
  const selected = await ctx.ui.select(state.title, state.actions);
61
70
  if (!selected) return;
62
- switch (selected as MainMenuAction | "Set up sync" | "Use existing settings") {
63
- case "Sync now":
71
+ switch (
72
+ selected as MainMenuAction | ContextualMenuAction | "Set up sync" | "Use existing settings"
73
+ ) {
74
+ case "Sync now (recommended)":
64
75
  await runCancellableOperation(ctx, "Checking current target…", "sync", runRoute, true);
65
76
  break;
77
+ case "Pull from remote": {
78
+ const result = await runCancellableOperation(
79
+ ctx,
80
+ "Checking remote changes…",
81
+ "pull",
82
+ runRoute,
83
+ true,
84
+ "Pull check cancelled; no local files were changed.",
85
+ );
86
+ if (result === "applied") return;
87
+ break;
88
+ }
89
+ case "Push to remote":
90
+ await runCancellableOperation(
91
+ ctx,
92
+ "Preparing push preview…",
93
+ "push",
94
+ runRoute,
95
+ true,
96
+ "Push preparation cancelled; no remote files were changed.",
97
+ );
98
+ break;
66
99
  case "Switch target": {
67
100
  const result = await showTargetSwitcher(ctx, runRoute);
68
101
  if (result === "pull-attempted") return;
@@ -75,23 +108,37 @@ export async function showSyncManager(
75
108
  case "Settings":
76
109
  await showSyncSettings(ctx, runRoute);
77
110
  break;
111
+ case "More…":
112
+ if ((await showMoreMenu(ctx, runRoute)) === "exit") return;
113
+ break;
78
114
  case "Manage targets & storage":
79
115
  await showManageMenu(ctx, runRoute);
80
116
  break;
81
117
  case "History & recovery":
82
118
  await showRecoveryMenu(ctx, runRoute);
83
119
  break;
120
+ case "Help":
121
+ await runRoute("help");
122
+ return;
84
123
  case "Set up sync":
85
124
  case "Use existing settings":
86
125
  await runRoute("init");
87
126
  continue;
88
- case "Help":
89
- await runRoute("help");
90
- return;
91
127
  }
92
128
  }
93
129
  }
94
130
 
131
+ async function showMoreMenu(ctx: ExtensionCommandContext, runRoute: RunRoute) {
132
+ const selected = await ctx.ui.select("More options", [...MORE_MENU_ACTIONS]);
133
+ if (!selected || selected === BACK) return;
134
+ if (selected === "Manage targets & storage") await showManageMenu(ctx, runRoute);
135
+ else if (selected === "History & recovery") await showRecoveryMenu(ctx, runRoute);
136
+ else {
137
+ await runRoute("help");
138
+ return "exit" as const;
139
+ }
140
+ }
141
+
95
142
  async function runCancellableOperation(
96
143
  ctx: ExtensionCommandContext,
97
144
  message: string,
@@ -158,7 +205,7 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
158
205
  "",
159
206
  "Settings file needs repair. Automatic sync and settings writes are paused.",
160
207
  `Error: ${safeTerminalText(errorMessage(error))}`,
161
- `File: ${safeTerminalText(localConfigPath())}`,
208
+ `File: ${safeTerminalText(await activeLocalConfigPath())}`,
162
209
  "",
163
210
  "Repair the JSON file, then reopen /sync.",
164
211
  ].join("\n"),
@@ -194,15 +241,34 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
194
241
  const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
195
242
  const recoverableLock =
196
243
  lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
244
+ const builtInCount = normalizeSyncFiles(config.syncFiles).length;
245
+ const extraFileCount = config.extraFiles.length;
246
+ const noSyncedContent = builtInCount === 0 && extraFileCount === 0 && !config.syncSessions;
247
+ const stateReadDisabled = liveLock || recoverableLock;
248
+ const syncState = stateReadDisabled
249
+ ? undefined
250
+ : await readStateForConfig(config).catch(() => undefined);
251
+ const lastAppliedSnapshot = stateReadDisabled
252
+ ? "Unavailable while operations are locked"
253
+ : syncState?.lastAppliedSnapshot
254
+ ? safeTerminalText(syncState.lastAppliedSnapshot)
255
+ : syncState
256
+ ? "Never synced"
257
+ : "Unavailable";
197
258
  return {
198
259
  title: [
199
260
  "Pi Sync",
200
261
  "",
201
262
  `Current target: ${safeTerminalText(target)}`,
202
263
  `Storage: ${storage}`,
203
- `Auto-sync: ${config.autoSync ? "On" : "Off"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
204
- "Last known sync: Remote not checked",
264
+ `Synced content: ${builtInCount} built-in group${builtInCount === 1 ? "" : "s"} · ${extraFileCount} extra file${extraFileCount === 1 ? "" : "s"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
265
+ `Auto-sync: ${config.autoSync ? "On" : "Off"}`,
266
+ `Last applied snapshot: ${lastAppliedSnapshot}`,
267
+ "Remote changes: Not checked",
205
268
  ...warnings.map((warning) => `Warning: ${safeTerminalText(warning)}`),
269
+ ...(noSyncedContent
270
+ ? ["", "No synced content is selected. Choose synced content in Settings before syncing."]
271
+ : []),
206
272
  ...(liveLock
207
273
  ? [
208
274
  "",
@@ -218,7 +284,9 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
218
284
  actions:
219
285
  liveLock || recoverableLock
220
286
  ? ["Status & changes", "History & recovery", "Help"]
221
- : [...MAIN_MENU_ACTIONS],
287
+ : noSyncedContent
288
+ ? ["Settings", "Switch target", "Status & changes", "More…"]
289
+ : [...MAIN_MENU_ACTIONS],
222
290
  };
223
291
  } catch (error) {
224
292
  const targets = ownRecord(raw.targets);
@@ -229,7 +297,7 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
229
297
  "Settings need attention. Automatic sync is paused.",
230
298
  `Current target: ${safeTerminalText(typeof raw.activeTarget === "string" ? raw.activeTarget : "default")}`,
231
299
  `Error: ${safeTerminalText(errorMessage(error))}`,
232
- `File: ${safeTerminalText(localConfigPath())}`,
300
+ `File: ${safeTerminalText(await activeLocalConfigPath())}`,
233
301
  "",
234
302
  "What do you want to do?",
235
303
  ].join("\n"),
package/src/paths.ts CHANGED
@@ -14,7 +14,12 @@ export function isDeniedPath(relativePath: string) {
14
14
  base.endsWith(".env") ||
15
15
  base.includes("secret") ||
16
16
  base.includes("token") ||
17
- base === "pi-sync.local.json"
17
+ base === "pi-sync.json" ||
18
+ base.startsWith("pi-sync.json.") ||
19
+ base.startsWith(".pi-sync.json.") ||
20
+ base === "pi-sync.local.json" ||
21
+ base.startsWith("pi-sync.local.json.") ||
22
+ base.startsWith(".pi-sync.local.json.")
18
23
  );
19
24
  }
20
25
 
@@ -42,7 +47,10 @@ export function encodeKey(key: string) {
42
47
  }
43
48
 
44
49
  export function posixJoin(...parts: string[]) {
45
- return parts.map((part) => trimSlashes(part)).filter(Boolean).join("/");
50
+ return parts
51
+ .map((part) => trimSlashes(part))
52
+ .filter(Boolean)
53
+ .join("/");
46
54
  }
47
55
 
48
56
  export function parentPaths(relativePath: string) {
@@ -5,7 +5,9 @@ import {
5
5
  DEFAULT_TARGET_SWITCH_ACTION,
6
6
  effectiveTargetRemoteIdentity,
7
7
  localConfigPath,
8
+ readLocalConfigDocument,
8
9
  readLocalConfigObject,
10
+ replaceLocalConfigDocument,
9
11
  resolveLegacyPartialConfig,
10
12
  resolveV2PartialConfig,
11
13
  stateDir,
@@ -15,6 +17,21 @@ import {
15
17
  import { withLock } from "./lock.js";
16
18
  import type { PartialConfig, StorageProfileSettings, SyncTargetSettings } from "./types.js";
17
19
 
20
+ let afterLegacySettingsReadHook: () => Promise<void> = async () => undefined;
21
+
22
+ export async function withLegacySettingsReadHookForTest<T>(
23
+ hook: () => Promise<void>,
24
+ run: () => Promise<T>,
25
+ ) {
26
+ const previous = afterLegacySettingsReadHook;
27
+ afterLegacySettingsReadHook = hook;
28
+ try {
29
+ return await run();
30
+ } finally {
31
+ afterLegacySettingsReadHook = previous;
32
+ }
33
+ }
34
+
18
35
  const LEGACY_FIELDS = new Set([
19
36
  "endpoint",
20
37
  "bucket",
@@ -42,21 +59,17 @@ export async function migrateLegacySettings(
42
59
  validateName(targetName, "target");
43
60
  validateName(storageProfileName, "storage profile");
44
61
  return withLock("settings-migration", async () => {
45
- const configPath = localConfigPath();
46
- const originalBytes = await fs.readFile(configPath);
47
- const current = await readLocalConfigObject();
48
- if (!current) throw new Error("No legacy pi-sync settings are available to migrate.");
62
+ const document = await readLocalConfigDocument();
63
+ if (!document) throw new Error("No legacy pi-sync settings are available to migrate.");
64
+ const { bytes: originalBytes, parsed: current } = document;
49
65
  if (current.version === 2) {
50
66
  throw new Error("pi-sync settings already use profiles and targets.");
51
67
  }
68
+ await afterLegacySettingsReadHook();
52
69
  const next = legacySettingsAsV2(current, targetName, storageProfileName);
53
70
  const backupPath = await writeMigrationBackup(originalBytes);
54
71
  await adoptLegacyState(current, next, targetName);
55
- const latestBytes = await fs.readFile(configPath);
56
- if (!latestBytes.equals(originalBytes)) {
57
- throw new Error("pi-sync settings changed during migration; no settings were replaced.");
58
- }
59
- await writeLocalConfigObject(next);
72
+ await replaceLocalConfigDocument(document, next);
60
73
  return { settings: next, backupPath };
61
74
  });
62
75
  }
package/src/sync.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { constants as fsConstants } from "node:fs";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { promisify } from "node:util";
@@ -17,7 +16,9 @@ import {
17
16
  validateCommandOptions,
18
17
  } from "./command.js";
19
18
  import {
19
+ activeLocalConfigPath,
20
20
  configuredTargetNames,
21
+ consumeLocalConfigMigrationNotice,
21
22
  deprecatedPiSyncEnvironmentWarnings,
22
23
  ensureStateDir,
23
24
  isEnabled,
@@ -29,6 +30,7 @@ import {
29
30
  localConfigTemplate,
30
31
  normalizeExtraFiles,
31
32
  normalizeSyncFiles,
33
+ readLocalConfigObject,
32
34
  readStateForConfig,
33
35
  sessionDirForApply,
34
36
  sessionTokenWarnings,
@@ -142,6 +144,8 @@ export default function sync(pi: ExtensionAPI) {
142
144
  } catch {
143
145
  setSyncTargetCompletions([]);
144
146
  }
147
+ const migrationNotice = consumeLocalConfigMigrationNotice();
148
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
145
149
  const warnings = deprecatedPiSyncEnvironmentWarnings();
146
150
  if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
147
151
  await autoSync(ctx);
@@ -279,12 +283,9 @@ async function autoPushSessions(ctx: ExtensionContext) {
279
283
 
280
284
  async function initConfig(ctx: ExtensionCommandContext) {
281
285
  const configPath = localConfigPath();
282
- try {
283
- await fs.access(configPath, fsConstants.F_OK);
284
- ctx.ui.notify(`Config already exists: ${configPath}`, "info");
286
+ if (await readLocalConfigObject()) {
287
+ ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
285
288
  return;
286
- } catch {
287
- // Create or guide setup below.
288
289
  }
289
290
 
290
291
  if (ctx.mode === "tui") {