@narumitw/pi-sync 0.49.4 → 0.49.6

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
@@ -252,6 +252,7 @@ The menu is preferred, while deterministic routes remain available:
252
252
  /sync sync [--setup <name>]
253
253
  /sync history [--setup <name>]
254
254
  /sync rollback <snapshot-id> [--setup <name>]
255
+ /sync migrate-state [--yes]
255
256
  /sync unlock --stale
256
257
  ```
257
258
 
@@ -274,17 +275,19 @@ dialog/notification protocol and does not gain settings mutation. Print and JSON
274
275
  | WebDAV | Verified strong conditional requests | Private settings username/app password | `<url>/<storage.path>` |
275
276
  | R2/S3 | Read-check-write-verify | Private settings credentials | `<bucket>/<storage.path>` |
276
277
 
277
- Git requires Git 2.30 or newer and a SHA-1-format remote repository. HTTPS userinfo, URL passwords, local paths, `file`, `git`, `ext`, and remote-helper transports are rejected. When editing a Git setup, changing its storage path also requires a new owned branch so the existing branch remains readable at its reviewed path. The private bare cache under `.pisync/git/` is rebuildable.
278
+ Git requires Git 2.30 or newer and a SHA-1-format remote repository. HTTPS userinfo, URL passwords, local paths, `file`, `git`, `ext`, and remote-helper transports are rejected. When editing a Git setup, changing its storage path also requires a new owned branch so the existing branch remains readable at its reviewed path. The private bare cache under `<agent-dir>/pi-sync/git/` is rebuildable.
278
279
 
279
280
  WebDAV requires HTTPS except loopback tests. URL credentials, query strings, fragments, unsafe redirects, weak/missing ETags, and ignored conditional headers fail closed. `/sync doctor` verifies collection and conditional-write behavior with an isolated probe.
280
281
 
281
282
  S3/R2 stages immutable bundles, rechecks the visible head before publication, and verifies afterward. Unlike Git/WebDAV, generic S3 does not provide an atomic compare-and-swap for `latest.json`; status review remains important for simultaneous writers.
282
283
 
283
- Before pull or rollback, pi-sync writes a backup under `.pisync/backups/`. Apply preflights paths and checksums, journals all mutations, restores the prior state after failures, and recovers interrupted journals on startup. Removing a local setup or connection never deletes remote data.
284
+ Before pull or rollback, pi-sync writes a backup under `<agent-dir>/pi-sync/backups/`. Apply preflights paths and checksums, journals all mutations, restores the prior state after failures, and recovers interrupted journals on startup. Removing a local setup or connection never deletes remote data.
285
+
286
+ The operational state root is `<agent-dir>/pi-sync/`. An existing installation continues using `<agent-dir>/.pisync/` and shows migration guidance on startup; it is never moved merely because no sync is active. Close every other Pi process, then run `/sync migrate-state` and confirm the review (or pass `--yes` for an already reviewed RPC workflow). The command serializes against every upgraded state/cache user and atomically renames `.pisync/` only when no legacy sync lock or guard is active. Close Pi instances running older pi-sync versions during the migration because they do not understand the migration guard. If both roots exist, or either root is a symlink or non-directory, pi-sync refuses stateful work instead of merging, following, or deleting data. Preserve both roots before manual recovery; with every Pi process closed and no destination conflict, rollback is an atomic rename from `pi-sync/` back to `.pisync/`.
284
287
 
285
288
  ## 🛡️ Safety notes
286
289
 
287
- - Canonical, legacy, temporary, and recovery settings paths are denied from snapshots.
290
+ - Canonical, legacy, temporary, and recovery settings paths are denied from snapshots; both `pi-sync/` and `.pisync/` state roots are permanently denied.
288
291
  - Push scans managed local content for common secret patterns.
289
292
  - Remote snapshot references, checksums, paths, manifests, response sizes, and publication revisions are validated.
290
293
  - Symlink parents, path escapes, duplicate paths, and unsafe file/directory replacement fail before local mutation.
@@ -302,9 +305,12 @@ Pi exposes terminal components rather than a semantic/ARIA tree. Release validat
302
305
  packages/pi-sync/
303
306
  ├── src/
304
307
  │ ├── index.ts
305
- │ ├── sync.ts
308
+ │ ├── sync-extension.ts # Lightweight Pi entry runtime and cached lazy loaders
309
+ │ ├── sync.ts # Compatibility barrel for package-local helpers
310
+ │ ├── sync-errors.ts # Lightweight setup/decision error contracts
306
311
  │ ├── config.ts
307
312
  │ ├── config-file.ts
313
+ │ ├── state-directory.ts
308
314
  │ ├── settings-management.ts
309
315
  │ ├── manager-ui.ts
310
316
  │ ├── storage-connections-ui.ts
@@ -318,7 +324,8 @@ packages/pi-sync/
318
324
  │ ├── s3-backend.ts
319
325
  │ ├── webdav-backend.ts
320
326
  │ ├── git-backend.ts
321
- │ ├── snapshot.ts
327
+ │ ├── snapshot-paths.ts # Eager recovery-safe session path helpers
328
+ │ ├── snapshot.ts # Loaded only for snapshot operations/session push
322
329
  │ └── *.ts
323
330
  ├── test/
324
331
  ├── README.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.49.4",
3
+ "version": "0.49.6",
4
4
  "description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/command.ts CHANGED
@@ -19,6 +19,7 @@ export const SYNC_COMMANDS = [
19
19
  { name: "sync", description: "Push or pull as needed" },
20
20
  { name: "history", description: "Show recent remote snapshots" },
21
21
  { name: "rollback", description: "Apply a previous snapshot", usageSuffix: " <snapshot>" },
22
+ { name: "migrate-state", description: "Move legacy state into pi-sync/" },
22
23
  { name: "unlock", description: "Remove a stale local lock", usageSuffix: " --stale" },
23
24
  ] as const;
24
25
 
@@ -60,6 +61,7 @@ const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]
60
61
  ],
61
62
  history: [SETUP_FLAG_COMPLETION],
62
63
  rollback: [...YES_FLAG_COMPLETIONS, SETUP_FLAG_COMPLETION],
64
+ "migrate-state": YES_FLAG_COMPLETIONS,
63
65
  unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
64
66
  };
65
67
 
@@ -119,7 +121,10 @@ export function validateCommandOptions(command: string, options: CommandOptions)
119
121
  if (options.setup && !setupAllowed.has(command)) {
120
122
  throw new Error(`--setup is not supported by /sync ${command}.`);
121
123
  }
122
- if ((options.yes || options.force) && !["push", "pull", "sync", "rollback"].includes(command)) {
124
+ if (options.yes && !["push", "pull", "sync", "rollback", "migrate-state"].includes(command)) {
125
+ throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
126
+ }
127
+ if (options.force && !["push", "pull", "sync", "rollback"].includes(command)) {
123
128
  throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
124
129
  }
125
130
  if (options.stale && command !== "unlock") {
package/src/config.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  normalizeGitRemoteIdentity,
27
27
  } from "./git-config.js";
28
28
  import { safeName } from "./paths.js";
29
+ import { stateDir } from "./state-directory.js";
29
30
  import { normalizeSyncInclude } from "./sync-policy.js";
30
31
  import type {
31
32
  AnySyncConfig,
@@ -751,9 +752,7 @@ function expandHome(value: string) {
751
752
  return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
752
753
  }
753
754
 
754
- export function stateDir() {
755
- return path.join(agentDir(), ".pisync");
756
- }
755
+ export { stateDir } from "./state-directory.js";
757
756
 
758
757
  export function localConfigTemplate(): PiSyncSettingsV3 {
759
758
  return {
@@ -1,7 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
4
  import {
6
5
  normalizeGitBranch,
7
6
  normalizeGitDirectory,
@@ -23,6 +22,7 @@ import {
23
22
  validateGitSnapshot,
24
23
  } from "./git-storage.js";
25
24
  import { posixJoin } from "./paths.js";
25
+ import { stateDir } from "./state-directory.js";
26
26
  import {
27
27
  type BackendDiagnostic,
28
28
  type ExpectedRemoteHead,
@@ -74,7 +74,7 @@ export class GitSyncBackend implements SyncBackend {
74
74
  if (!this.allowLocalRemotes) assertProductionRemote(config.profile.remote);
75
75
  this.identity = gitBackendIdentity(config);
76
76
  this.destination = gitDestination(config);
77
- this.cacheRoot = options.cacheRoot ?? path.join(getAgentDir(), ".pisync", "git");
77
+ this.cacheRoot = options.cacheRoot ?? path.join(stateDir(), "git");
78
78
  this.cacheDir = path.join(this.cacheRoot, this.identity.slice("git:".length), "repository.git");
79
79
  this.commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
80
80
  this.postCommitTimeoutMs = options.postCommitTimeoutMs ?? POST_COMMIT_TIMEOUT_MS;
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { default } from "./sync.js";
1
+ export { default } from "./sync-extension.js";
@@ -0,0 +1,2 @@
1
+ export const LOCK_GUARD_STALE_MS = 30_000;
2
+ export const LOCK_GUARD_UPDATE_MS = 10_000;
package/src/lock.ts CHANGED
@@ -1,53 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import {
3
- mkdir,
4
- mkdirSync,
5
- realpath,
6
- realpathSync,
7
- rmdir,
8
- rmdirSync,
9
- stat,
10
- statSync,
11
- utimes,
12
- utimesSync,
13
- } from "node:fs";
14
2
  import fs from "node:fs/promises";
15
3
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
4
  import lockfile from "proper-lockfile";
17
5
  import { ensureStateDir, lockPath } from "./config.js";
6
+ import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "./lock-policy.js";
7
+ import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
18
8
  import type { CommandOptions, LockFile } from "./types.js";
19
9
 
20
10
  const LOCK_STALE_MS = 30 * 60 * 1000;
21
- const GUARD_STALE_MS = 30_000;
22
- const GUARD_UPDATE_MS = 10_000;
23
11
  const MAX_PROCESS_ID = 2_147_483_647;
24
12
 
25
- type LockfileFsAdapter = {
26
- mkdir: typeof mkdir;
27
- mkdirSync: typeof mkdirSync;
28
- realpath: typeof realpath;
29
- realpathSync: typeof realpathSync;
30
- rmdir: typeof rmdir;
31
- rmdirSync: typeof rmdirSync;
32
- stat: typeof stat;
33
- statSync: typeof statSync;
34
- utimes: typeof utimes;
35
- utimesSync: typeof utimesSync;
36
- };
37
-
38
- const LOCKFILE_FS_ADAPTER: LockfileFsAdapter = {
39
- mkdir,
40
- mkdirSync,
41
- realpath,
42
- realpathSync,
43
- rmdir,
44
- rmdirSync,
45
- stat,
46
- statSync,
47
- utimes,
48
- utimesSync,
49
- };
50
-
51
13
  interface Guard {
52
14
  release: () => Promise<void>;
53
15
  throwIfCompromised: () => void;
@@ -175,7 +137,7 @@ export function isLockGuardHeld() {
175
137
  fs: LOCKFILE_FS_ADAPTER,
176
138
  lockfilePath: `${lockPath()}.guard`,
177
139
  realpath: false,
178
- stale: GUARD_STALE_MS,
140
+ stale: LOCK_GUARD_STALE_MS,
179
141
  });
180
142
  }
181
143
 
@@ -267,8 +229,8 @@ async function acquireGuard(): Promise<Guard> {
267
229
  fs: LOCKFILE_FS_ADAPTER,
268
230
  lockfilePath: `${lockPath()}.guard`,
269
231
  realpath: false,
270
- stale: GUARD_STALE_MS,
271
- update: GUARD_UPDATE_MS,
232
+ stale: LOCK_GUARD_STALE_MS,
233
+ update: LOCK_GUARD_UPDATE_MS,
272
234
  onCompromised: (error) => {
273
235
  compromisedError = error;
274
236
  },
@@ -0,0 +1,25 @@
1
+ import {
2
+ mkdir,
3
+ mkdirSync,
4
+ realpath,
5
+ realpathSync,
6
+ rmdir,
7
+ rmdirSync,
8
+ stat,
9
+ statSync,
10
+ utimes,
11
+ utimesSync,
12
+ } from "node:fs";
13
+
14
+ export const LOCKFILE_FS_ADAPTER = {
15
+ mkdir,
16
+ mkdirSync,
17
+ realpath,
18
+ realpathSync,
19
+ rmdir,
20
+ rmdirSync,
21
+ stat,
22
+ statSync,
23
+ utimes,
24
+ utimesSync,
25
+ };
package/src/paths.ts CHANGED
@@ -9,6 +9,8 @@ export function isDeniedPath(relativePath: string) {
9
9
  segments.includes("node_modules") ||
10
10
  segments.includes(".git") ||
11
11
  segments.includes(".pisync") ||
12
+ segments.includes("pi-sync") ||
13
+ segments.includes(".pi-sync-state-migration.lock") ||
12
14
  base === ".env" ||
13
15
  base.startsWith(".env.") ||
14
16
  base.endsWith(".env") ||
@@ -8,9 +8,12 @@ import {
8
8
  syncSetupReviewIdentity,
9
9
  updateLocalConfig,
10
10
  } from "./config.js";
11
+ import { SetupPullRequiresUiError } from "./sync-errors.js";
11
12
  import { safeTerminalText } from "./sync-format.js";
12
13
  import type { OnSwitchAction } from "./types.js";
13
14
 
15
+ export { SetupPullRequiresUiError } from "./sync-errors.js";
16
+
14
17
  export const SETUP_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
15
18
  label: string;
16
19
  value: OnSwitchAction;
@@ -34,8 +37,6 @@ export async function saveOnSwitch(action: OnSwitchAction, signal?: AbortSignal)
34
37
 
35
38
  export type SetupPullOutcome = "applied" | "cancelled";
36
39
 
37
- export class SetupPullRequiresUiError extends Error {}
38
-
39
40
  export interface SetupSwitchResult {
40
41
  pullApplied: boolean;
41
42
  }
@@ -0,0 +1,13 @@
1
+ import path from "node:path";
2
+
3
+ function expandHome(value: string) {
4
+ if (value === "~") return process.env.HOME ?? value;
5
+ if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
6
+ return value;
7
+ }
8
+
9
+ export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
10
+ return configuredSessionDir
11
+ ? path.resolve(expandHome(configuredSessionDir))
12
+ : path.resolve(root, "sessions");
13
+ }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { agentDir, stateDir } from "./config.js";
5
5
  import { withLock } from "./lock.js";
6
6
  import { assertWithinRoot, isPathInside } from "./paths.js";
7
- import { sessionStorageRoot } from "./snapshot.js";
7
+ import { sessionStorageRoot } from "./snapshot-paths.js";
8
8
  import type { SnapshotApplyPlan } from "./types.js";
9
9
 
10
10
  const JOURNAL_VERSION = 1;
package/src/snapshot.ts CHANGED
@@ -5,8 +5,10 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { agentDir, configuredSessionDir } from "./config.js";
7
7
  import { isDeniedPath, posixJoin, safeJoin, toPosix } from "./paths.js";
8
+ import { sessionStorageRoot } from "./snapshot-paths.js";
8
9
 
9
10
  export { isDeniedPath } from "./paths.js";
11
+ export { sessionStorageRoot } from "./snapshot-paths.js";
10
12
 
11
13
  import {
12
14
  canonicalSnapshotPathForConfig,
@@ -39,12 +41,6 @@ const SECRET_PATTERNS = [
39
41
  /gh[pousr]_[A-Za-z0-9_]{20,}/,
40
42
  ];
41
43
 
42
- function expandHome(value: string) {
43
- if (value === "~") return process.env.HOME ?? value;
44
- if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
45
- return value;
46
- }
47
-
48
44
  function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
49
45
  const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
50
46
  if (exact) return exact;
@@ -223,12 +219,6 @@ export function isSessionFilePath(relativePath: string) {
223
219
  return isSessionPath(normalized) && normalized.endsWith(".jsonl");
224
220
  }
225
221
 
226
- export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
227
- return configuredSessionDir
228
- ? path.resolve(expandHome(configuredSessionDir))
229
- : path.resolve(root, "sessions");
230
- }
231
-
232
222
  export function sessionSnapshotPathFromAbsolute(
233
223
  sessionFile: string,
234
224
  configuredSessionDir?: string,
@@ -0,0 +1,184 @@
1
+ import { lstatSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import lockfile from "proper-lockfile";
6
+ import { LOCK_GUARD_STALE_MS } from "./lock-policy.js";
7
+ import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
8
+
9
+ const CANONICAL_DIRECTORY_NAME = "pi-sync";
10
+ const LEGACY_DIRECTORY_NAME = ".pisync";
11
+ const MIGRATION_LOCK_NAME = ".pi-sync-state-migration.lock";
12
+ const MIGRATION_LOCK_STALE_MS = 30_000;
13
+ const MIGRATION_LOCK_UPDATE_MS = 10_000;
14
+
15
+ export type StateDirectoryPreparation =
16
+ | { status: "ready" }
17
+ | { status: "migrated"; message: string }
18
+ | { status: "deferred"; message: string };
19
+
20
+ interface StateDirectoryGuard {
21
+ release: () => Promise<void>;
22
+ throwIfCompromised: () => void;
23
+ }
24
+
25
+ export function stateDir() {
26
+ const roots = inspectStateRoots();
27
+ if (roots.canonical) return canonicalStateDir();
28
+ if (roots.legacy) return legacyStateDir();
29
+ return canonicalStateDir();
30
+ }
31
+
32
+ export function legacyStateDir() {
33
+ return path.join(getAgentDir(), LEGACY_DIRECTORY_NAME);
34
+ }
35
+
36
+ export function stateDirectoryMigrationNotice() {
37
+ const roots = inspectStateRoots();
38
+ if (!roots.legacy) return undefined;
39
+ return "Legacy pi-sync state is still stored in .pisync. Close other Pi sessions, then run /sync migrate-state to move it to pi-sync/.";
40
+ }
41
+
42
+ export async function withStateDirectoryAccess<T>(fn: () => Promise<T>): Promise<T> {
43
+ return runWithStateDirectoryGuard(await acquireStateDirectoryGuard(), fn);
44
+ }
45
+
46
+ export async function migrateLegacyStateDirectory(): Promise<StateDirectoryPreparation> {
47
+ const initial = inspectStateRoots();
48
+ if (initial.canonical || !initial.legacy) return { status: "ready" };
49
+
50
+ let guard: StateDirectoryGuard;
51
+ try {
52
+ guard = await acquireStateDirectoryGuard();
53
+ } catch (error) {
54
+ if (!isLockHeldError(error)) throw error;
55
+ return {
56
+ status: "deferred",
57
+ message:
58
+ "pi-sync state migration was deferred because another state user is active. Close other Pi sessions and retry.",
59
+ };
60
+ }
61
+ return runWithStateDirectoryGuard(guard, async () => {
62
+ const roots = inspectStateRoots();
63
+ if (roots.canonical || !roots.legacy) return { status: "ready" };
64
+
65
+ const legacyLock = path.join(legacyStateDir(), "lock");
66
+ const legacyGuardHeld = await lockfile.check(legacyLock, {
67
+ fs: LOCKFILE_FS_ADAPTER,
68
+ lockfilePath: `${legacyLock}.guard`,
69
+ realpath: false,
70
+ stale: LOCK_GUARD_STALE_MS,
71
+ });
72
+ if (legacyGuardHeld || (await pathExists(legacyLock))) {
73
+ return {
74
+ status: "deferred",
75
+ message:
76
+ "pi-sync state migration was deferred because the legacy directory is busy. Close other Pi sessions, clear any confirmed stale sync lock, and retry.",
77
+ };
78
+ }
79
+ guard.throwIfCompromised();
80
+ await fs.rename(legacyStateDir(), canonicalStateDir());
81
+ guard.throwIfCompromised();
82
+ inspectStateRoots();
83
+ return {
84
+ status: "migrated",
85
+ message: `Migrated pi-sync state from ${legacyStateDir()} to ${canonicalStateDir()}.`,
86
+ };
87
+ });
88
+ }
89
+
90
+ async function acquireStateDirectoryGuard(): Promise<StateDirectoryGuard> {
91
+ let compromisedError: Error | undefined;
92
+ const release = await lockfile.lock(getAgentDir(), {
93
+ fs: LOCKFILE_FS_ADAPTER,
94
+ lockfilePath: migrationLockPath(),
95
+ realpath: false,
96
+ stale: MIGRATION_LOCK_STALE_MS,
97
+ update: MIGRATION_LOCK_UPDATE_MS,
98
+ retries: { retries: 20, minTimeout: 10, maxTimeout: 50 },
99
+ onCompromised: (error) => {
100
+ compromisedError = error;
101
+ },
102
+ });
103
+ return {
104
+ release,
105
+ throwIfCompromised: () => {
106
+ if (compromisedError) throw compromisedError;
107
+ },
108
+ };
109
+ }
110
+
111
+ async function runWithStateDirectoryGuard<T>(guard: StateDirectoryGuard, fn: () => Promise<T>) {
112
+ let result: T | undefined;
113
+ let failed = false;
114
+ let failure: unknown;
115
+ try {
116
+ guard.throwIfCompromised();
117
+ result = await fn();
118
+ guard.throwIfCompromised();
119
+ } catch (error) {
120
+ failed = true;
121
+ failure = error;
122
+ }
123
+ try {
124
+ await guard.release();
125
+ } catch (error) {
126
+ if (!failed) {
127
+ failed = true;
128
+ failure = error;
129
+ }
130
+ }
131
+ if (failed) throw failure;
132
+ return result as T;
133
+ }
134
+
135
+ function isLockHeldError(error: unknown) {
136
+ return (error as NodeJS.ErrnoException).code === "ELOCKED";
137
+ }
138
+
139
+ function canonicalStateDir() {
140
+ return path.join(getAgentDir(), CANONICAL_DIRECTORY_NAME);
141
+ }
142
+
143
+ function migrationLockPath() {
144
+ return path.join(getAgentDir(), MIGRATION_LOCK_NAME);
145
+ }
146
+
147
+ function inspectStateRoots() {
148
+ const canonical = isDirectoryRoot(canonicalStateDir(), "canonical");
149
+ const legacy = isDirectoryRoot(legacyStateDir(), "legacy");
150
+ if (canonical && legacy) {
151
+ throw new Error(
152
+ `Both legacy ${legacyStateDir()} and canonical ${canonicalStateDir()} pi-sync state directories exist. Close all Pi sessions and reconcile the directories manually; pi-sync will not merge or delete either directory.`,
153
+ );
154
+ }
155
+ return { canonical, legacy };
156
+ }
157
+
158
+ function isDirectoryRoot(directory: string, label: string) {
159
+ try {
160
+ const entry = lstatSync(directory);
161
+ if (entry.isSymbolicLink()) {
162
+ throw new Error(
163
+ `Refusing to use ${label} pi-sync state directory symbolic link: ${directory}`,
164
+ );
165
+ }
166
+ if (!entry.isDirectory()) {
167
+ throw new Error(`${label} pi-sync state path is not a directory: ${directory}`);
168
+ }
169
+ return true;
170
+ } catch (error) {
171
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ async function pathExists(filePath: string) {
177
+ try {
178
+ await fs.lstat(filePath);
179
+ return true;
180
+ } catch (error) {
181
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
182
+ throw error;
183
+ }
184
+ }
@@ -1,4 +1,5 @@
1
1
  import { syncConfigReviewIdentity } from "./config.js";
2
+ import { SyncDecisionRequiredError } from "./sync-errors.js";
2
3
  import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
3
4
  import { includeFromSelectionConfig } from "./sync-policy.js";
4
5
  import { syncPolicyChanged } from "./sync-state.js";
@@ -29,15 +30,10 @@ export interface SyncDecision {
29
30
  directMessage: string;
30
31
  }
31
32
 
32
- export class SyncDecisionRequiredError extends Error {
33
- readonly decision: SyncDecision;
34
-
35
- constructor(decision: SyncDecision) {
36
- super(decision.directMessage);
37
- this.name = "SyncDecisionRequiredError";
38
- this.decision = decision;
39
- }
40
- }
33
+ export {
34
+ isSyncDecisionRequiredError,
35
+ SyncDecisionRequiredError,
36
+ } from "./sync-errors.js";
41
37
 
42
38
  interface CreateSyncDecisionOptions {
43
39
  kind: SyncDecisionKind;
@@ -104,10 +100,6 @@ export function createSyncDecision(options: CreateSyncDecisionOptions) {
104
100
  });
105
101
  }
106
102
 
107
- export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
108
- return error instanceof SyncDecisionRequiredError;
109
- }
110
-
111
103
  function safeList(values: readonly string[]) {
112
104
  return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
113
105
  }
@@ -0,0 +1,23 @@
1
+ import type { SyncDecision } from "./sync-decision.js";
2
+
3
+ export class SetupPullRequiresUiError extends Error {}
4
+
5
+ export class SyncDecisionRequiredError extends Error {
6
+ readonly decision: SyncDecision;
7
+
8
+ constructor(decision: SyncDecision) {
9
+ super(decision.directMessage);
10
+ this.name = "SyncDecisionRequiredError";
11
+ this.decision = decision;
12
+ }
13
+ }
14
+
15
+ export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
16
+ return error instanceof SyncDecisionRequiredError;
17
+ }
18
+
19
+ export function errorMessage(error: unknown) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
22
+ return message.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
23
+ }