@narumitw/pi-sync 0.49.4 → 0.49.5

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.
@@ -305,6 +308,7 @@ packages/pi-sync/
305
308
  │ ├── sync.ts
306
309
  │ ├── config.ts
307
310
  │ ├── config-file.ts
311
+ │ ├── state-directory.ts
308
312
  │ ├── settings-management.ts
309
313
  │ ├── manager-ui.ts
310
314
  │ ├── storage-connections-ui.ts
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.5",
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;
@@ -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") ||
@@ -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
+ }
package/src/sync.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  parseOptions,
10
10
  resolveSyncCommand,
11
11
  setSyncSetupCompletions,
12
+ splitArgs,
12
13
  usage,
13
14
  validateCommandOptions,
14
15
  } from "./command.js";
@@ -32,6 +33,11 @@ import { unlock, withLock } from "./lock.js";
32
33
  import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
33
34
  import { createSnapshot } from "./snapshot.js";
34
35
  import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
36
+ import {
37
+ migrateLegacyStateDirectory,
38
+ stateDirectoryMigrationNotice,
39
+ withStateDirectoryAccess,
40
+ } from "./state-directory.js";
35
41
  import { isSyncDecisionRequiredError } from "./sync-decision.js";
36
42
  import { errorMessage } from "./sync-format.js";
37
43
  import { hasLocalChanges } from "./sync-state.js";
@@ -74,7 +80,9 @@ export default function sync(pi: ExtensionAPI) {
74
80
  "/sync requires TUI or RPC mode so results and safety prompts are observable.",
75
81
  );
76
82
  }
77
- await handleCommand(args, ctx, sessionAbort.signal);
83
+ const run = () => handleCommand(args, ctx, sessionAbort.signal);
84
+ if (splitArgs(args)[0] === "migrate-state") await run();
85
+ else await withStateDirectoryAccess(run);
78
86
  },
79
87
  });
80
88
 
@@ -86,24 +94,11 @@ export default function sync(pi: ExtensionAPI) {
86
94
  const signal = sessionAbort.signal;
87
95
  ctx.ui.setStatus(STATUS_KEY, undefined);
88
96
  try {
89
- await recoverSnapshotTransactionsOnStartup();
90
- if (signal.aborted) return;
97
+ await withStateDirectoryAccess(() => startSession(ctx, signal));
91
98
  } catch (error) {
92
99
  if (signal.aborted) return;
93
- ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
94
- return;
95
- }
96
- try {
97
- setSyncSetupCompletions(await configuredSyncSetupNames());
98
- if (signal.aborted) return;
99
- } catch {
100
- if (signal.aborted) return;
101
- setSyncSetupCompletions([]);
100
+ ctx.ui.notify(`pi-sync state access failed: ${errorMessage(error)}`, "error");
102
101
  }
103
- const migrationNotice = consumeLocalConfigMigrationNotice();
104
- if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
105
- if (signal.aborted) return;
106
- await autoSync(ctx, signal);
107
102
  });
108
103
 
109
104
  pi.on("session_shutdown", async (event, ctx) => {
@@ -115,7 +110,16 @@ export default function sync(pi: ExtensionAPI) {
115
110
  const reason =
116
111
  typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
117
112
  try {
118
- if (reason !== "reload") await autoPushSessions(ctx, signal);
113
+ if (reason !== "reload") {
114
+ await withStateDirectoryAccess(async () => {
115
+ if (signal.aborted) return;
116
+ await autoPushSessions(ctx, signal);
117
+ });
118
+ }
119
+ } catch (error) {
120
+ if (!signal.aborted) {
121
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
122
+ }
119
123
  } finally {
120
124
  if (shutdownAbort === controller) shutdownAbort = undefined;
121
125
  }
@@ -124,6 +128,36 @@ export default function sync(pi: ExtensionAPI) {
124
128
  });
125
129
  }
126
130
 
131
+ async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
132
+ if (signal.aborted) return;
133
+ try {
134
+ const migrationNotice = stateDirectoryMigrationNotice();
135
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
136
+ } catch (error) {
137
+ ctx.ui.notify(`pi-sync state directory requires attention: ${errorMessage(error)}`, "error");
138
+ return;
139
+ }
140
+ try {
141
+ await recoverSnapshotTransactionsOnStartup();
142
+ if (signal.aborted) return;
143
+ } catch (error) {
144
+ if (signal.aborted) return;
145
+ ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
146
+ return;
147
+ }
148
+ try {
149
+ setSyncSetupCompletions(await configuredSyncSetupNames());
150
+ if (signal.aborted) return;
151
+ } catch {
152
+ if (signal.aborted) return;
153
+ setSyncSetupCompletions([]);
154
+ }
155
+ const migrationNotice = consumeLocalConfigMigrationNotice();
156
+ if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
157
+ if (signal.aborted) return;
158
+ await autoSync(ctx, signal);
159
+ }
160
+
127
161
  async function handleCommand(
128
162
  rawArgs: string,
129
163
  ctx: ExtensionCommandContext,
@@ -237,6 +271,9 @@ async function executeCommand(
237
271
  await withLock("rollback", () => operations.rollback(ctx, options));
238
272
  return { kind: "completed" };
239
273
  }
274
+ case "migrate-state":
275
+ await migrateStateDirectory(ctx, options);
276
+ return { kind: "completed" };
240
277
  case "unlock":
241
278
  await unlock(ctx, options);
242
279
  return { kind: "completed" };
@@ -256,6 +293,33 @@ async function executeCommand(
256
293
  }
257
294
  }
258
295
 
296
+ async function migrateStateDirectory(ctx: ExtensionCommandContext, options: CommandOptions) {
297
+ const notice = stateDirectoryMigrationNotice();
298
+ if (!notice) {
299
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
300
+ return;
301
+ }
302
+ if (
303
+ !options.yes &&
304
+ !(await ctx.ui.confirm(
305
+ "Migrate pi-sync state directory",
306
+ "Confirm that every other Pi process is closed. pi-sync will atomically rename .pisync/ to pi-sync/ without merging or deleting either root.",
307
+ { signal: options.signal },
308
+ ))
309
+ ) {
310
+ ctx.ui.notify("pi-sync state migration cancelled.", "info");
311
+ return;
312
+ }
313
+ throwIfAborted(options.signal);
314
+ const result = await migrateLegacyStateDirectory();
315
+ throwIfAborted(options.signal);
316
+ if (result.status === "ready") {
317
+ ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
318
+ return;
319
+ }
320
+ ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
321
+ }
322
+
259
323
  async function autoSync(ctx: ExtensionContext, signal: AbortSignal) {
260
324
  try {
261
325
  const partial = await loadPartialConfig();