@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 +12 -5
- 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/index.ts +1 -1
- 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/setup-switch.ts +3 -2
- package/src/snapshot-paths.ts +13 -0
- package/src/snapshot-transaction.ts +1 -1
- package/src/snapshot.ts +2 -12
- package/src/state-directory.ts +184 -0
- package/src/sync-decision.ts +5 -13
- package/src/sync-errors.ts +23 -0
- package/src/sync-extension.ts +567 -0
- package/src/sync-format.ts +3 -4
- package/src/sync.ts +1 -448
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.
|
|
@@ -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
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/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "./sync.js";
|
|
1
|
+
export { default } from "./sync-extension.js";
|
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") ||
|
package/src/setup-switch.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sync-decision.ts
CHANGED
|
@@ -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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|