@narumitw/pi-sync 0.49.3 → 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 +34 -8
- package/package.json +7 -4
- package/src/command.ts +6 -1
- package/src/config.ts +2 -3
- package/src/file-selection.ts +51 -1
- package/src/git-backend.ts +5 -2
- package/src/git-storage.ts +17 -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/remote-selection-ui.ts +279 -0
- package/src/remote-snapshot.ts +59 -0
- package/src/s3-backend.ts +4 -0
- package/src/settings-ui.ts +16 -1
- package/src/snapshot-codec.ts +3 -0
- package/src/snapshot.ts +33 -2
- package/src/state-directory.ts +184 -0
- package/src/sync-backend.ts +3 -1
- package/src/sync-format.ts +6 -0
- package/src/sync-operations.ts +44 -10
- package/src/sync-policy.ts +183 -7
- package/src/sync.ts +81 -17
- package/src/types.ts +9 -0
- package/src/webdav-backend.ts +4 -0
package/README.md
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
- Immutable snapshots, secret scanning, local locks, conflict checks, pull backups, transactional apply, and recovery journals.
|
|
12
12
|
- Exact reviewed storage paths that do not change when a local sync setup is renamed.
|
|
13
13
|
- One ordered `sync.include` list for Pi roots, safe agent-relative paths, and the privacy-sensitive `sessions` root.
|
|
14
|
+
- Portable, credential-free snapshot selection with explicit cross-environment review and adoption.
|
|
14
15
|
- Atomic private settings writes that preserve unknown fields and reject stale concurrent edits.
|
|
15
|
-
- Fail-closed validation for missing references, mixed backend fields, duplicate remote locations, unsafe
|
|
16
|
+
- Fail-closed validation for missing references, mixed backend fields, duplicate remote locations, unsafe or oversized selection policies, malformed credentials, and credential-bearing Git URLs.
|
|
16
17
|
|
|
17
18
|
## 📦 Install
|
|
18
19
|
|
|
@@ -29,7 +30,7 @@ pi -e npm:@narumitw/pi-sync
|
|
|
29
30
|
Try a local checkout:
|
|
30
31
|
|
|
31
32
|
```bash
|
|
32
|
-
pi -e ./
|
|
33
|
+
pi -e ./packages/pi-sync
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
## 🚀 Quick start
|
|
@@ -63,7 +64,9 @@ and confirmations.
|
|
|
63
64
|
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
|
|
64
65
|
direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
|
|
65
66
|
error. The flow names the current sync setup and explains whether local content, remote content, or
|
|
66
|
-
the included-content policy changed.
|
|
67
|
+
the included-content policy changed. If the active remote snapshot explicitly selects different
|
|
68
|
+
content, automatic sync and pulls pause until you adopt the remote policy in Settings or publish the
|
|
69
|
+
local policy with a reviewed force push.
|
|
67
70
|
|
|
68
71
|
Choose **Review differences (recommended)** to inspect the exact affected paths without changing
|
|
69
72
|
local files, remote data, or sync state. Then choose one reviewed direction:
|
|
@@ -193,11 +196,28 @@ Safe agent-relative custom files or directories may also be included. Absolute p
|
|
|
193
196
|
An empty array is valid. It means no useful transfer is selected: **Sync now** reports the condition and does not claim that the setup is up to date. Unselected content remains unmanaged locally and is preserved when republishing existing remote snapshots.
|
|
194
197
|
|
|
195
198
|
The Included Content editor is a standard bounded multi-select backed by one in-memory draft. Toggles
|
|
196
|
-
never write settings.
|
|
199
|
+
never write settings. **Add custom path…** accepts a safe agent-relative file or directory even when
|
|
200
|
+
it does not exist locally yet, allowing a new environment to select content that exists only in the
|
|
201
|
+
remote snapshot. Leaving the editor opens an exact Include/Exclude review with **Save changes**,
|
|
197
202
|
**Discard changes**, and **Continue editing**; only reviewed Save publishes, while Continue preserves
|
|
198
203
|
the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
|
|
199
204
|
the manual `sync.include` path.
|
|
200
205
|
|
|
206
|
+
Every new snapshot stores the normalized included-content selection separately from the files that
|
|
207
|
+
happened to exist. This preserves selected-but-missing paths without syncing `pi-sync.json`, storage
|
|
208
|
+
credentials, automatic-sync preferences, or setup names. **Settings → Remote included content** reads
|
|
209
|
+
the active snapshot and offers **Adopt remote included content**, **Keep local included content**,
|
|
210
|
+
**Review paths**, and **Cancel** when local and remote selections differ. Adoption revalidates the
|
|
211
|
+
remote head, saves local settings atomically, and does not pull files or write sync state; review
|
|
212
|
+
**Sync now** separately. A reviewed force push keeps local selection and explicitly replaces the
|
|
213
|
+
remote policy while preserving eligible unmanaged remote files.
|
|
214
|
+
|
|
215
|
+
Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather
|
|
216
|
+
than silently expanding local scope. Status reports matches and exact local-only/remote-only paths.
|
|
217
|
+
Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
|
|
218
|
+
clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
|
|
219
|
+
preserved-unmanaged intent cannot be reconstructed. Use **Add custom path…** for any needed path.
|
|
220
|
+
|
|
201
221
|
Adding `sessions` requires a privacy acknowledgement in interactive flows. Session JSONL can contain prompts, tool output, file paths, images, and secrets. Automatic apply protects the currently open session file; restart Pi or resume a pulled session to use newly synchronized conversations.
|
|
202
222
|
|
|
203
223
|
### Unsupported old settings and recovery
|
|
@@ -232,6 +252,7 @@ The menu is preferred, while deterministic routes remain available:
|
|
|
232
252
|
/sync sync [--setup <name>]
|
|
233
253
|
/sync history [--setup <name>]
|
|
234
254
|
/sync rollback <snapshot-id> [--setup <name>]
|
|
255
|
+
/sync migrate-state [--yes]
|
|
235
256
|
/sync unlock --stale
|
|
236
257
|
```
|
|
237
258
|
|
|
@@ -254,17 +275,19 @@ dialog/notification protocol and does not gain settings mutation. Print and JSON
|
|
|
254
275
|
| WebDAV | Verified strong conditional requests | Private settings username/app password | `<url>/<storage.path>` |
|
|
255
276
|
| R2/S3 | Read-check-write-verify | Private settings credentials | `<bucket>/<storage.path>` |
|
|
256
277
|
|
|
257
|
-
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.
|
|
258
279
|
|
|
259
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.
|
|
260
281
|
|
|
261
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.
|
|
262
283
|
|
|
263
|
-
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/`.
|
|
264
287
|
|
|
265
288
|
## 🛡️ Safety notes
|
|
266
289
|
|
|
267
|
-
- 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.
|
|
268
291
|
- Push scans managed local content for common secret patterns.
|
|
269
292
|
- Remote snapshot references, checksums, paths, manifests, response sizes, and publication revisions are validated.
|
|
270
293
|
- Symlink parents, path escapes, duplicate paths, and unsafe file/directory replacement fail before local mutation.
|
|
@@ -279,17 +302,20 @@ Pi exposes terminal components rather than a semantic/ARIA tree. Release validat
|
|
|
279
302
|
## 🗂️ Package layout
|
|
280
303
|
|
|
281
304
|
```text
|
|
282
|
-
|
|
305
|
+
packages/pi-sync/
|
|
283
306
|
├── src/
|
|
284
307
|
│ ├── index.ts
|
|
285
308
|
│ ├── sync.ts
|
|
286
309
|
│ ├── config.ts
|
|
287
310
|
│ ├── config-file.ts
|
|
311
|
+
│ ├── state-directory.ts
|
|
288
312
|
│ ├── settings-management.ts
|
|
289
313
|
│ ├── manager-ui.ts
|
|
290
314
|
│ ├── storage-connections-ui.ts
|
|
291
315
|
│ ├── sync-setups-ui.ts
|
|
292
316
|
│ ├── file-selection.ts
|
|
317
|
+
│ ├── remote-selection-ui.ts
|
|
318
|
+
│ ├── remote-snapshot.ts
|
|
293
319
|
│ ├── sync-operations.ts
|
|
294
320
|
│ ├── sync-backend.ts
|
|
295
321
|
│ ├── backend-factory.ts # lazy selected-backend loader
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.49.
|
|
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",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"./src/index.ts"
|
|
31
31
|
]
|
|
32
32
|
},
|
|
33
|
+
"piExtension": {
|
|
34
|
+
"lifecycle": "stable"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"check": "biome check . && npm run typecheck",
|
|
35
38
|
"format": "biome check --write .",
|
|
@@ -42,8 +45,8 @@
|
|
|
42
45
|
},
|
|
43
46
|
"devDependencies": {
|
|
44
47
|
"@biomejs/biome": "2.5.7",
|
|
45
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
46
|
-
"@earendil-works/pi-tui": "0.
|
|
48
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
49
|
+
"@earendil-works/pi-tui": "0.84.1",
|
|
47
50
|
"@types/proper-lockfile": "^4.1.4",
|
|
48
51
|
"typescript": "7.0.2"
|
|
49
52
|
},
|
|
@@ -54,6 +57,6 @@
|
|
|
54
57
|
"repository": {
|
|
55
58
|
"type": "git",
|
|
56
59
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
57
|
-
"directory": "
|
|
60
|
+
"directory": "packages/pi-sync"
|
|
58
61
|
}
|
|
59
62
|
}
|
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/file-selection.ts
CHANGED
|
@@ -6,12 +6,14 @@ import { updateSyncSetup } from "./settings-management.js";
|
|
|
6
6
|
import {
|
|
7
7
|
BUILT_IN_SYNC_ROOTS,
|
|
8
8
|
isSafeCustomIncludePath,
|
|
9
|
+
normalizeSyncInclude,
|
|
9
10
|
syncIncludeSelection,
|
|
10
11
|
} from "./sync-policy.js";
|
|
11
12
|
|
|
12
13
|
const BUILT_IN_PREFIX = "builtin:";
|
|
13
14
|
const CUSTOM_PREFIX = "custom:";
|
|
14
15
|
const SESSIONS_ID = "sessions";
|
|
16
|
+
const ADD_CUSTOM_ID = "add-custom";
|
|
15
17
|
|
|
16
18
|
interface SelectionDraft {
|
|
17
19
|
builtIns: Set<string>;
|
|
@@ -107,7 +109,7 @@ async function showDraftEditor(
|
|
|
107
109
|
customCandidates: string[],
|
|
108
110
|
signal?: AbortSignal,
|
|
109
111
|
) {
|
|
110
|
-
const menu = defineMenu<undefined, "editor", "toggle", ExtensionCommandContext>({
|
|
112
|
+
const menu = defineMenu<undefined, "editor", "toggle" | "addCustom", ExtensionCommandContext>({
|
|
111
113
|
start: "editor",
|
|
112
114
|
screens: {
|
|
113
115
|
editor: () => ({
|
|
@@ -139,6 +141,15 @@ async function showDraftEditor(
|
|
|
139
141
|
},
|
|
140
142
|
],
|
|
141
143
|
action: "toggle",
|
|
144
|
+
actions: [
|
|
145
|
+
{
|
|
146
|
+
id: ADD_CUSTOM_ID,
|
|
147
|
+
label: "Add custom path…",
|
|
148
|
+
description:
|
|
149
|
+
"Include an agent-relative file or directory even when it exists only remotely.",
|
|
150
|
+
action: "addCustom",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
142
153
|
hint: "close",
|
|
143
154
|
doneLabel: "Review changes",
|
|
144
155
|
}),
|
|
@@ -148,6 +159,45 @@ async function showDraftEditor(
|
|
|
148
159
|
updateDraft(draft, itemId, selected === true);
|
|
149
160
|
return { kind: "stay" };
|
|
150
161
|
},
|
|
162
|
+
addCustom: async ({ ctx: actionCtx, signal: actionSignal }) => {
|
|
163
|
+
const entered = await actionCtx.ui.input(
|
|
164
|
+
"Add included content",
|
|
165
|
+
"Agent-relative path, for example custom.toml or snippets",
|
|
166
|
+
{ signal: actionSignal },
|
|
167
|
+
);
|
|
168
|
+
if (actionSignal.aborted) return { kind: "stay" };
|
|
169
|
+
const requested = entered?.trim();
|
|
170
|
+
if (!requested) return { kind: "stay" };
|
|
171
|
+
const existing = customCandidates.find(
|
|
172
|
+
(candidate) => candidate.toLowerCase() === requested.toLowerCase(),
|
|
173
|
+
);
|
|
174
|
+
const relativePath = existing ?? requested;
|
|
175
|
+
if (draft.custom.has(relativePath)) return { kind: "stay" };
|
|
176
|
+
try {
|
|
177
|
+
if (!isSafeCustomIncludePath(relativePath)) {
|
|
178
|
+
throw new Error("Enter a safe agent-relative file or directory path.");
|
|
179
|
+
}
|
|
180
|
+
normalizeSyncInclude([
|
|
181
|
+
...draft.builtIns,
|
|
182
|
+
...draft.custom,
|
|
183
|
+
...(draft.sessions ? ["sessions"] : []),
|
|
184
|
+
relativePath,
|
|
185
|
+
]);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (actionSignal.aborted) return { kind: "stay" };
|
|
188
|
+
actionCtx.ui.notify(
|
|
189
|
+
`Could not add included content: ${safeTerminalText(error instanceof Error ? error.message : String(error))}`,
|
|
190
|
+
"error",
|
|
191
|
+
);
|
|
192
|
+
return { kind: "stay" };
|
|
193
|
+
}
|
|
194
|
+
if (!existing) {
|
|
195
|
+
customCandidates.push(relativePath);
|
|
196
|
+
customCandidates.sort((left, right) => left.localeCompare(right));
|
|
197
|
+
}
|
|
198
|
+
draft.custom.add(relativePath);
|
|
199
|
+
return { kind: "stay" };
|
|
200
|
+
},
|
|
151
201
|
},
|
|
152
202
|
});
|
|
153
203
|
await runMenu(ctx, menu, {
|
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;
|
|
@@ -140,6 +140,7 @@ export class GitSyncBackend implements SyncBackend {
|
|
|
140
140
|
...(manifest.snapshotSyncSessions === undefined
|
|
141
141
|
? {}
|
|
142
142
|
: { syncSessions: manifest.snapshotSyncSessions }),
|
|
143
|
+
...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
|
|
143
144
|
files,
|
|
144
145
|
};
|
|
145
146
|
validateGitSnapshot(snapshot, manifest, this.config.destination.namespace);
|
|
@@ -174,6 +175,7 @@ export class GitSyncBackend implements SyncBackend {
|
|
|
174
175
|
...(snapshot.syncSessions === undefined
|
|
175
176
|
? {}
|
|
176
177
|
: { snapshotSyncSessions: snapshot.syncSessions }),
|
|
178
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
177
179
|
files: files.map(({ path: filePath, sha256: fileSha, size }) => ({
|
|
178
180
|
path: filePath,
|
|
179
181
|
sha256: fileSha,
|
|
@@ -688,6 +690,7 @@ function remoteHead(sha: string, manifest: GitManifest, identity: string): Remot
|
|
|
688
690
|
createdAt: manifest.createdAt,
|
|
689
691
|
machine: manifest.machine,
|
|
690
692
|
syncSessions: manifest.syncSessions,
|
|
693
|
+
...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
|
|
691
694
|
};
|
|
692
695
|
}
|
|
693
696
|
|
package/src/git-storage.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { portableSnapshotSelection, snapshotSelectionInclude } from "./sync-policy.js";
|
|
3
|
+
import type { Snapshot, SnapshotSelection } from "./types.js";
|
|
3
4
|
|
|
4
5
|
export const GIT_MANIFEST_VERSION = 2;
|
|
5
6
|
export const MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
|
|
@@ -23,6 +24,7 @@ export interface GitManifest {
|
|
|
23
24
|
profile: string;
|
|
24
25
|
syncSessions: boolean;
|
|
25
26
|
snapshotSyncSessions?: boolean;
|
|
27
|
+
selection?: SnapshotSelection;
|
|
26
28
|
files: GitManifestFile[];
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -81,11 +83,13 @@ export function requireGitManifest(value: unknown): GitManifest {
|
|
|
81
83
|
"profile",
|
|
82
84
|
"syncSessions",
|
|
83
85
|
...(manifest.snapshotSyncSessions === undefined ? [] : ["snapshotSyncSessions"]),
|
|
86
|
+
...(manifest.selection === undefined ? [] : ["selection"]),
|
|
84
87
|
"files",
|
|
85
88
|
])
|
|
86
89
|
) {
|
|
87
90
|
throw new Error("Git publication manifest is malformed.");
|
|
88
91
|
}
|
|
92
|
+
if (manifest.selection !== undefined) portableSnapshotSelection(manifest.selection);
|
|
89
93
|
let total = 0;
|
|
90
94
|
const paths = new Set<string>();
|
|
91
95
|
for (const rawFile of manifest.files) {
|
|
@@ -126,6 +130,12 @@ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, n
|
|
|
126
130
|
snapshot.profile !== manifest.profile ||
|
|
127
131
|
snapshot.syncSessions !== manifest.snapshotSyncSessions ||
|
|
128
132
|
syncSessions !== manifest.syncSessions ||
|
|
133
|
+
!sameOptionalInclude(
|
|
134
|
+
snapshotSelectionInclude(snapshot),
|
|
135
|
+
manifest.selection === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: portableSnapshotSelection(manifest.selection).include,
|
|
138
|
+
) ||
|
|
129
139
|
prepared.length !== manifest.files.length ||
|
|
130
140
|
prepared.some((file, index) => {
|
|
131
141
|
const expected = manifest.files[index];
|
|
@@ -142,6 +152,7 @@ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, n
|
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
export function prepareGitSnapshot(snapshot: Snapshot, namespace: string) {
|
|
155
|
+
snapshotSelectionInclude(snapshot);
|
|
145
156
|
if (
|
|
146
157
|
snapshot.version !== SNAPSHOT_VERSION ||
|
|
147
158
|
typeof snapshot.id !== "string" ||
|
|
@@ -242,6 +253,11 @@ export function validateGitPublicationTree(
|
|
|
242
253
|
return manifest.files.map((file) => byPath.get(filePath(file.path)) as GitTreeEntry);
|
|
243
254
|
}
|
|
244
255
|
|
|
256
|
+
function sameOptionalInclude(left: string[] | undefined, right: string[] | undefined) {
|
|
257
|
+
if (!left || !right) return left === right;
|
|
258
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
259
|
+
}
|
|
260
|
+
|
|
245
261
|
function isSafeSnapshotPath(value: unknown): value is string {
|
|
246
262
|
return (
|
|
247
263
|
typeof value === "string" &&
|
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") ||
|