@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 +7 -3
- package/package.json +1 -1
- package/src/command.ts +6 -1
- package/src/config.ts +2 -3
- package/src/git-backend.ts +2 -2
- package/src/lock-policy.ts +2 -0
- package/src/lock.ts +5 -43
- package/src/lockfile-fs.ts +25 -0
- package/src/paths.ts +2 -0
- package/src/state-directory.ts +184 -0
- package/src/sync.ts +81 -17
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
|
|
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
|
|
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
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 (
|
|
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
|
|
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 {
|
package/src/git-backend.ts
CHANGED
|
@@ -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(
|
|
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/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:
|
|
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:
|
|
271
|
-
update:
|
|
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
|
-
|
|
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
|
|
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
|
|
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")
|
|
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();
|