@narumitw/pi-sync 0.29.2 → 0.30.0
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 +23 -10
- package/package.json +1 -1
- package/src/command.ts +1 -1
- package/src/config-file.ts +545 -0
- package/src/config.ts +106 -18
- package/src/manager-ui.ts +80 -12
- package/src/paths.ts +6 -1
- package/src/settings-management.ts +22 -9
- package/src/sync.ts +7 -6
package/README.md
CHANGED
|
@@ -79,30 +79,42 @@ A configured manager shows:
|
|
|
79
79
|
```text
|
|
80
80
|
Current target: home
|
|
81
81
|
Storage: Cloudflare R2 · personal-pi
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
Synced content: 9 built-in groups · 0 extra files · Sessions: Off
|
|
83
|
+
Auto-sync: On
|
|
84
|
+
Last applied snapshot: snapshot-id (or Never synced)
|
|
85
|
+
Remote changes: Not checked
|
|
84
86
|
```
|
|
85
87
|
|
|
86
88
|
Its primary actions are:
|
|
87
89
|
|
|
88
|
-
- **Sync now** — conservatively decide whether to push, pull, or do nothing
|
|
90
|
+
- **Sync now (recommended)** — conservatively decide whether to push, pull, or do nothing
|
|
91
|
+
- **Pull from remote** — check the remote snapshot, preview exact local writes/deletes, then confirm
|
|
92
|
+
- **Push to remote** — scan and preview exact remote publication changes, then confirm
|
|
89
93
|
- **Switch target** — preview the target change, then follow the configured pull behavior
|
|
90
94
|
- **Status & changes** — perform a cancellable read-only remote check
|
|
91
95
|
- **Settings** — control post-switch pulling, automatic sync, and synced content
|
|
92
|
-
- **
|
|
93
|
-
- **History & recovery** — browse snapshots, preview rollback, diagnose setup, or recover a stale lock
|
|
94
|
-
- **Help**
|
|
96
|
+
- **More…** — open one shallow level containing target/storage management, history/recovery, Help, and Back
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
The menu does not query remote storage merely to open. It shows the last locally applied snapshot and marks remote changes as unchecked until an operation runs. When no synced content is selected, transfer actions are hidden and Settings becomes the first action. During an active or recoverable lock, mutation actions remain unavailable.
|
|
99
|
+
|
|
100
|
+
Push and pull keep their existing concrete previews and confirmation prompts. Pull creates a local backup before applying; push scans locally managed content for likely secrets before publication. Escape cancels a pre-commit check without changing local or remote files. Once publication or apply begins, cancellation is disabled. Conflicts never force automatically; inspect **Status & changes** and use an explicit direct route only after reviewing the conflict.
|
|
101
|
+
|
|
102
|
+
Escape exits the main menu. **More…** and secondary menus provide Back; dirty synced-content drafts provide Save, Discard, and Continue editing. Cancellation before publication has no side effects.
|
|
97
103
|
|
|
98
104
|
## ⚙️ Settings
|
|
99
105
|
|
|
100
106
|
The private user settings file is:
|
|
101
107
|
|
|
102
108
|
```text
|
|
103
|
-
${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-sync.
|
|
109
|
+
${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-sync.json
|
|
104
110
|
```
|
|
105
111
|
|
|
112
|
+
On first read, an existing `pi-sync.local.json` is migrated byte-for-byte to `pi-sync.json`
|
|
113
|
+
with private POSIX `0600` permissions. The private legacy file is retained as a recovery copy and may
|
|
114
|
+
be deleted after verifying the new file. If both files exist, `pi-sync.json` takes precedence and the
|
|
115
|
+
legacy file remains untouched. Malformed, invalid, symlinked, changed, or otherwise unsafe legacy
|
|
116
|
+
files are never overwritten automatically.
|
|
117
|
+
|
|
106
118
|
A version 2 example:
|
|
107
119
|
|
|
108
120
|
```json
|
|
@@ -208,7 +220,7 @@ Environment-overridden fields are read-only in interactive settings. Cloudflare
|
|
|
208
220
|
|
|
209
221
|
### Legacy flat settings
|
|
210
222
|
|
|
211
|
-
Existing flat
|
|
223
|
+
Existing flat settings continue to work unchanged as a synthetic `default` target/profile, including settings migrated from the legacy `pi-sync.local.json` filename. The old flat `profile` remains the remote namespace.
|
|
212
224
|
|
|
213
225
|
Adding a second target offers an explicit migration preview. On confirmation pi-sync:
|
|
214
226
|
|
|
@@ -292,7 +304,7 @@ Sessions can contain prompts, model output, tool results, file paths, images, an
|
|
|
292
304
|
|
|
293
305
|
## 🛡️ Safety notes
|
|
294
306
|
|
|
295
|
-
- Credentials stay local and are
|
|
307
|
+
- Credentials stay local; canonical, legacy, temporary, and migration-recovery settings files are always excluded from snapshots.
|
|
296
308
|
- Push refuses common secret patterns in locally managed files.
|
|
297
309
|
- Pull/rollback reject unsafe paths, duplicate paths, checksum mismatches, symlink parents, and file/directory replacement hazards before mutation.
|
|
298
310
|
- Unmanaged local/remote files remain preserved.
|
|
@@ -320,6 +332,7 @@ extensions/pi-sync/
|
|
|
320
332
|
│ ├── sync.ts # Command and lifecycle orchestration
|
|
321
333
|
│ ├── manager-ui.ts # Goal-oriented menus and setup/management flows
|
|
322
334
|
│ ├── file-selection.ts # Transactional synced-content editor
|
|
335
|
+
│ ├── config-file.ts # Private settings I/O and legacy filename migration
|
|
323
336
|
│ ├── settings-management.ts # Profiles, targets, migration, and atomic saves
|
|
324
337
|
│ ├── settings-ui.ts # SettingsList interaction and serialized saves
|
|
325
338
|
│ ├── target-switch.ts # Post-switch prompt, policy, and target handoff
|
package/package.json
CHANGED
package/src/command.ts
CHANGED
|
@@ -230,6 +230,6 @@ export function usage() {
|
|
|
230
230
|
return [
|
|
231
231
|
"Usage: /sync <command>",
|
|
232
232
|
`Commands: ${commands}`,
|
|
233
|
-
"Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.
|
|
233
|
+
"Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.json (or $PI_CODING_AGENT_DIR/pi-sync.json). Existing PI_SYNC_* overrides still work but are deprecated for future major-version removal.",
|
|
234
234
|
].join("\n");
|
|
235
235
|
}
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
constants as fsConstants,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
realpath,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmdir,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
stat,
|
|
11
|
+
statSync,
|
|
12
|
+
utimes,
|
|
13
|
+
utimesSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import lockfile from "proper-lockfile";
|
|
19
|
+
|
|
20
|
+
const CONFIG_FILE_NAME = "pi-sync.json";
|
|
21
|
+
const LEGACY_CONFIG_FILE_NAME = "pi-sync.local.json";
|
|
22
|
+
const configMigrationNotices = new Map<string, string>();
|
|
23
|
+
const legacyPresenceNoticed = new Set<string>();
|
|
24
|
+
const CONFIG_LOCK_STALE_MS = 30_000;
|
|
25
|
+
const CONFIG_LOCK_UPDATE_MS = 10_000;
|
|
26
|
+
const LOCKFILE_FS_ADAPTER = {
|
|
27
|
+
mkdir,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
realpath,
|
|
30
|
+
realpathSync,
|
|
31
|
+
rmdir,
|
|
32
|
+
rmdirSync,
|
|
33
|
+
stat,
|
|
34
|
+
statSync,
|
|
35
|
+
utimes,
|
|
36
|
+
utimesSync,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type LinkFile = (source: string, destination: string) => Promise<void>;
|
|
40
|
+
let linkConfigFile: LinkFile = (source, destination) => fs.link(source, destination);
|
|
41
|
+
let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
|
|
42
|
+
let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
|
|
43
|
+
|
|
44
|
+
export type FileIdentity = { dev: number; ino: number };
|
|
45
|
+
|
|
46
|
+
export async function withConfigFileLinkForTest<T>(
|
|
47
|
+
link: LinkFile,
|
|
48
|
+
run: () => Promise<T>,
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
const previous = linkConfigFile;
|
|
51
|
+
linkConfigFile = link;
|
|
52
|
+
try {
|
|
53
|
+
return await run();
|
|
54
|
+
} finally {
|
|
55
|
+
linkConfigFile = previous;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function withConfigReplacementInstalledHookForTest<T>(
|
|
60
|
+
hook: () => Promise<void>,
|
|
61
|
+
run: () => Promise<T>,
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const previous = afterReplacementInstalledHook;
|
|
64
|
+
afterReplacementInstalledHook = hook;
|
|
65
|
+
try {
|
|
66
|
+
return await run();
|
|
67
|
+
} finally {
|
|
68
|
+
afterReplacementInstalledHook = previous;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function withConfigQuarantinedHookForTest<T>(
|
|
73
|
+
hook: () => Promise<void>,
|
|
74
|
+
run: () => Promise<T>,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const previous = afterConfigQuarantinedHook;
|
|
77
|
+
afterConfigQuarantinedHook = hook;
|
|
78
|
+
try {
|
|
79
|
+
return await run();
|
|
80
|
+
} finally {
|
|
81
|
+
afterConfigQuarantinedHook = previous;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ConfigSnapshot = {
|
|
86
|
+
bytes: Buffer;
|
|
87
|
+
identity: FileIdentity;
|
|
88
|
+
parsed: Record<string, unknown>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type LocalConfigDocument = ConfigSnapshot & { path: string };
|
|
92
|
+
|
|
93
|
+
export function localConfigPath() {
|
|
94
|
+
return path.join(getAgentDir(), CONFIG_FILE_NAME);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function legacyLocalConfigPath() {
|
|
98
|
+
return path.join(getAgentDir(), LEGACY_CONFIG_FILE_NAME);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function activeLocalConfigPath() {
|
|
102
|
+
return withLocalConfigFileLock(async () => {
|
|
103
|
+
const canonicalPath = localConfigPath();
|
|
104
|
+
if (await pathExists(canonicalPath)) return canonicalPath;
|
|
105
|
+
const legacyPath = legacyLocalConfigPath();
|
|
106
|
+
return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function consumeLocalConfigMigrationNotice() {
|
|
111
|
+
const configPath = localConfigPath();
|
|
112
|
+
const notice = configMigrationNotices.get(configPath);
|
|
113
|
+
configMigrationNotices.delete(configPath);
|
|
114
|
+
return notice;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function readMigratingLocalConfigDocument(
|
|
118
|
+
validateForMigration: (settings: Record<string, unknown>) => void,
|
|
119
|
+
): Promise<LocalConfigDocument | undefined> {
|
|
120
|
+
return withLocalConfigFileLock(async () => {
|
|
121
|
+
const configPath = await prepareLocalConfigPath(validateForMigration);
|
|
122
|
+
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
123
|
+
return snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function replaceLocalConfigDocument(
|
|
128
|
+
document: LocalConfigDocument,
|
|
129
|
+
value: Record<string, unknown>,
|
|
130
|
+
) {
|
|
131
|
+
return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function replaceLocalConfigDocumentUnlocked(
|
|
135
|
+
document: LocalConfigDocument,
|
|
136
|
+
value: Record<string, unknown>,
|
|
137
|
+
) {
|
|
138
|
+
const nextBytes = Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
139
|
+
const canonicalPath = localConfigPath();
|
|
140
|
+
if (document.path !== canonicalPath) {
|
|
141
|
+
if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
|
|
142
|
+
let installed: FileIdentity;
|
|
143
|
+
try {
|
|
144
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
147
|
+
throw new Error("Canonical settings were created concurrently; no settings were replaced.");
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
if (!(await configDocumentStillMatches(document))) {
|
|
152
|
+
await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
|
|
153
|
+
throw settingsChangedError();
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const quarantinePath = await claimCanonicalConfigDocument(document);
|
|
159
|
+
let installed: FileIdentity;
|
|
160
|
+
try {
|
|
161
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
164
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
165
|
+
throw new Error("Canonical settings changed concurrently; no settings were replaced.");
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
await afterReplacementInstalledHook();
|
|
170
|
+
if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
|
|
171
|
+
await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
|
|
172
|
+
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
173
|
+
throw settingsChangedError();
|
|
174
|
+
}
|
|
175
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
176
|
+
await syncParentDirectory(canonicalPath).catch(() => undefined);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function prepareLocalConfigPath(
|
|
180
|
+
validateForMigration: (settings: Record<string, unknown>) => void,
|
|
181
|
+
) {
|
|
182
|
+
const canonicalPath = localConfigPath();
|
|
183
|
+
const legacyPath = legacyLocalConfigPath();
|
|
184
|
+
if (await pathExists(canonicalPath)) {
|
|
185
|
+
const legacyStatus = await secureIgnoredLegacyIfPresent(legacyPath);
|
|
186
|
+
if (legacyStatus !== "missing" && !legacyPresenceNoticed.has(canonicalPath)) {
|
|
187
|
+
legacyPresenceNoticed.add(canonicalPath);
|
|
188
|
+
recordConfigMigrationNotice(
|
|
189
|
+
canonicalPath,
|
|
190
|
+
legacyStatus === "private"
|
|
191
|
+
? `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence. Delete ${LEGACY_CONFIG_FILE_NAME} after confirming your settings.`
|
|
192
|
+
: `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence, but pi-sync could not verify them as a private regular file. Secure or delete the legacy path after confirming your settings.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return canonicalPath;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const legacy = await readConfigSnapshotIfExists(legacyPath);
|
|
199
|
+
if (!legacy) return canonicalPath;
|
|
200
|
+
validateForMigration(legacy.parsed);
|
|
201
|
+
|
|
202
|
+
let installedIdentity: FileIdentity;
|
|
203
|
+
try {
|
|
204
|
+
installedIdentity = await installPrivateConfigExclusively(canonicalPath, legacy.bytes);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
207
|
+
recordConfigMigrationNotice(
|
|
208
|
+
canonicalPath,
|
|
209
|
+
`${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} was created concurrently and takes precedence.`,
|
|
210
|
+
);
|
|
211
|
+
return canonicalPath;
|
|
212
|
+
}
|
|
213
|
+
recordConfigMigrationNotice(
|
|
214
|
+
canonicalPath,
|
|
215
|
+
`Could not migrate ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the legacy settings were used for this session and were not changed.`,
|
|
216
|
+
);
|
|
217
|
+
return legacyPath;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!(await configSnapshotStillMatches(legacyPath, legacy))) {
|
|
221
|
+
const removed = await quarantineAndRemoveConfigIfMatchesUnlocked(
|
|
222
|
+
canonicalPath,
|
|
223
|
+
installedIdentity,
|
|
224
|
+
legacy.bytes,
|
|
225
|
+
);
|
|
226
|
+
recordConfigMigrationNotice(
|
|
227
|
+
canonicalPath,
|
|
228
|
+
removed
|
|
229
|
+
? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
|
|
230
|
+
: `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
|
|
231
|
+
);
|
|
232
|
+
return removed ? legacyPath : canonicalPath;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
legacyPresenceNoticed.add(canonicalPath);
|
|
236
|
+
recordConfigMigrationNotice(
|
|
237
|
+
canonicalPath,
|
|
238
|
+
`pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
|
|
239
|
+
);
|
|
240
|
+
return canonicalPath;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function secureIgnoredLegacyIfPresent(filePath: string) {
|
|
244
|
+
let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
245
|
+
try {
|
|
246
|
+
pathStat = await fs.lstat(filePath);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing" as const;
|
|
249
|
+
return "unsafe" as const;
|
|
250
|
+
}
|
|
251
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile()) return "unsafe" as const;
|
|
252
|
+
|
|
253
|
+
let handle: fs.FileHandle | undefined;
|
|
254
|
+
try {
|
|
255
|
+
handle = await fs.open(filePath, "r");
|
|
256
|
+
const openedStat = await handle.stat();
|
|
257
|
+
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
258
|
+
return "unsafe" as const;
|
|
259
|
+
}
|
|
260
|
+
if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
|
|
261
|
+
await handle.chmod(0o600);
|
|
262
|
+
}
|
|
263
|
+
return "private" as const;
|
|
264
|
+
} catch {
|
|
265
|
+
return "unsafe" as const;
|
|
266
|
+
} finally {
|
|
267
|
+
await handle?.close().catch(() => undefined);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnapshot | undefined> {
|
|
272
|
+
let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
273
|
+
try {
|
|
274
|
+
pathStat = await fs.lstat(filePath);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
if (pathStat.isSymbolicLink()) {
|
|
280
|
+
throw new Error(`Refusing to read symlinked pi-sync config: ${filePath}`);
|
|
281
|
+
}
|
|
282
|
+
if (!pathStat.isFile()) throw new Error(`pi-sync config is not a regular file: ${filePath}`);
|
|
283
|
+
|
|
284
|
+
const handle = await fs.open(filePath, "r");
|
|
285
|
+
try {
|
|
286
|
+
const openedStat = await handle.stat();
|
|
287
|
+
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
288
|
+
throw new Error(`pi-sync config changed while opening: ${filePath}`);
|
|
289
|
+
}
|
|
290
|
+
if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
|
|
291
|
+
await handle.chmod(0o600);
|
|
292
|
+
}
|
|
293
|
+
const bytes = await handle.readFile();
|
|
294
|
+
return {
|
|
295
|
+
bytes,
|
|
296
|
+
identity: { dev: openedStat.dev, ino: openedStat.ino },
|
|
297
|
+
parsed: parseConfigObject(bytes, filePath),
|
|
298
|
+
};
|
|
299
|
+
} finally {
|
|
300
|
+
await handle.close();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseConfigObject(bytes: Buffer, filePath: string) {
|
|
305
|
+
let parsed: unknown;
|
|
306
|
+
try {
|
|
307
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
308
|
+
} catch {
|
|
309
|
+
throw new SyntaxError(`Invalid JSON in pi-sync config: ${filePath}`);
|
|
310
|
+
}
|
|
311
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
312
|
+
throw new Error(`pi-sync config must contain a JSON object: ${filePath}`);
|
|
313
|
+
}
|
|
314
|
+
return parsed as Record<string, unknown>;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function installPrivateConfigExclusively(
|
|
318
|
+
filePath: string,
|
|
319
|
+
bytes: Buffer,
|
|
320
|
+
allowCopyFallback = false,
|
|
321
|
+
) {
|
|
322
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
323
|
+
const temporaryPath = path.join(
|
|
324
|
+
path.dirname(filePath),
|
|
325
|
+
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
|
|
326
|
+
);
|
|
327
|
+
let handle: fs.FileHandle | undefined;
|
|
328
|
+
try {
|
|
329
|
+
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
330
|
+
await handle.writeFile(bytes);
|
|
331
|
+
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
332
|
+
await handle.sync();
|
|
333
|
+
await handle.close();
|
|
334
|
+
handle = undefined;
|
|
335
|
+
try {
|
|
336
|
+
await linkConfigFile(temporaryPath, filePath);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (!allowCopyFallback || !isUnsupportedLinkError(error)) throw error;
|
|
339
|
+
await fs.copyFile(temporaryPath, filePath, fsConstants.COPYFILE_EXCL);
|
|
340
|
+
}
|
|
341
|
+
const installed = await fs.lstat(filePath);
|
|
342
|
+
try {
|
|
343
|
+
if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
|
|
344
|
+
await syncParentDirectory(filePath).catch(() => undefined);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
await quarantineAndRemoveConfigIfMatchesUnlocked(
|
|
347
|
+
filePath,
|
|
348
|
+
{ dev: installed.dev, ino: installed.ino },
|
|
349
|
+
bytes,
|
|
350
|
+
);
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
return { dev: installed.dev, ino: installed.ino };
|
|
354
|
+
} finally {
|
|
355
|
+
await handle?.close().catch(() => undefined);
|
|
356
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function configDocumentStillMatches(document: LocalConfigDocument) {
|
|
361
|
+
return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function claimCanonicalConfigDocument(document: LocalConfigDocument) {
|
|
365
|
+
const quarantinePath = path.join(
|
|
366
|
+
path.dirname(document.path),
|
|
367
|
+
`.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
|
|
368
|
+
);
|
|
369
|
+
try {
|
|
370
|
+
await fs.rename(document.path, quarantinePath);
|
|
371
|
+
await syncParentDirectory(document.path);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
await restoreQuarantinedConfig(document.path, quarantinePath);
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
|
|
377
|
+
await restoreQuarantinedConfig(document.path, quarantinePath);
|
|
378
|
+
throw settingsChangedError();
|
|
379
|
+
}
|
|
380
|
+
if (await pathExists(document.path)) {
|
|
381
|
+
await restoreQuarantinedConfig(document.path, quarantinePath);
|
|
382
|
+
throw new Error("Canonical settings changed concurrently; no settings were replaced.");
|
|
383
|
+
}
|
|
384
|
+
return quarantinePath;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function settingsChangedError() {
|
|
388
|
+
return new Error("pi-sync settings changed during migration; no settings were replaced.");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isUnsupportedLinkError(error: unknown) {
|
|
392
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
393
|
+
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
|
|
397
|
+
return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function quarantineAndRemoveConfigIfMatches(
|
|
401
|
+
filePath: string,
|
|
402
|
+
identity: FileIdentity,
|
|
403
|
+
expectedBytes: Buffer,
|
|
404
|
+
) {
|
|
405
|
+
return withLocalConfigFileLock(() =>
|
|
406
|
+
quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function quarantineAndRemoveConfigIfMatchesUnlocked(
|
|
411
|
+
filePath: string,
|
|
412
|
+
identity: FileIdentity,
|
|
413
|
+
expectedBytes: Buffer,
|
|
414
|
+
) {
|
|
415
|
+
const quarantinePath = path.join(
|
|
416
|
+
path.dirname(filePath),
|
|
417
|
+
`.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
|
|
418
|
+
);
|
|
419
|
+
try {
|
|
420
|
+
await fs.rename(filePath, quarantinePath);
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
await afterConfigQuarantinedHook();
|
|
425
|
+
try {
|
|
426
|
+
await syncParentDirectory(filePath);
|
|
427
|
+
} catch {
|
|
428
|
+
await restoreQuarantinedConfig(filePath, quarantinePath);
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
|
|
433
|
+
if (!matches) {
|
|
434
|
+
await restoreQuarantinedConfig(filePath, quarantinePath);
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
if (await pathExists(filePath)) {
|
|
438
|
+
await fs.rm(quarantinePath, { force: true });
|
|
439
|
+
await syncParentDirectory(filePath);
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
await fs.rm(quarantinePath);
|
|
443
|
+
await syncParentDirectory(filePath);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function fileIdentityAndContentsMatch(
|
|
448
|
+
filePath: string,
|
|
449
|
+
identity: FileIdentity,
|
|
450
|
+
expectedBytes: Buffer,
|
|
451
|
+
) {
|
|
452
|
+
try {
|
|
453
|
+
const current = await fs.lstat(filePath);
|
|
454
|
+
if (current.isSymbolicLink()) return false;
|
|
455
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino) return false;
|
|
456
|
+
return (await fs.readFile(filePath)).equals(expectedBytes);
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
|
|
463
|
+
try {
|
|
464
|
+
await linkConfigFile(quarantinePath, filePath);
|
|
465
|
+
await fs.rm(quarantinePath);
|
|
466
|
+
await syncParentDirectory(filePath);
|
|
467
|
+
return;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (isUnsupportedLinkError(error)) {
|
|
470
|
+
try {
|
|
471
|
+
await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
|
|
472
|
+
if (process.platform !== "win32") {
|
|
473
|
+
await fs.chmod(filePath, 0o600);
|
|
474
|
+
await fs.chmod(quarantinePath, 0o600);
|
|
475
|
+
}
|
|
476
|
+
await syncParentDirectory(filePath);
|
|
477
|
+
return;
|
|
478
|
+
} catch (copyError) {
|
|
479
|
+
if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
480
|
+
}
|
|
481
|
+
} else if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const [current, quarantined] = await Promise.all([
|
|
487
|
+
fs.readFile(filePath),
|
|
488
|
+
fs.readFile(quarantinePath),
|
|
489
|
+
]);
|
|
490
|
+
if (current.equals(quarantined)) await fs.rm(quarantinePath);
|
|
491
|
+
else if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
492
|
+
await syncParentDirectory(filePath);
|
|
493
|
+
} catch {
|
|
494
|
+
// Preserve the quarantine rather than risk deleting settings that changed concurrently.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
|
|
499
|
+
const configPath = localConfigPath();
|
|
500
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
501
|
+
let compromisedError: Error | undefined;
|
|
502
|
+
const release = await lockfile.lock(configPath, {
|
|
503
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
504
|
+
lockfilePath: `${configPath}.mutation-lock`,
|
|
505
|
+
realpath: false,
|
|
506
|
+
stale: CONFIG_LOCK_STALE_MS,
|
|
507
|
+
update: CONFIG_LOCK_UPDATE_MS,
|
|
508
|
+
retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
|
|
509
|
+
onCompromised: (error) => {
|
|
510
|
+
compromisedError = error;
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
try {
|
|
514
|
+
const result = await run();
|
|
515
|
+
if (compromisedError) throw compromisedError;
|
|
516
|
+
return result;
|
|
517
|
+
} finally {
|
|
518
|
+
await release();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function syncParentDirectory(filePath: string) {
|
|
523
|
+
if (process.platform === "win32") return;
|
|
524
|
+
let handle: fs.FileHandle | undefined;
|
|
525
|
+
try {
|
|
526
|
+
handle = await fs.open(path.dirname(filePath), "r");
|
|
527
|
+
await handle.sync();
|
|
528
|
+
} finally {
|
|
529
|
+
await handle?.close().catch(() => undefined);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function pathExists(filePath: string) {
|
|
534
|
+
try {
|
|
535
|
+
await fs.lstat(filePath);
|
|
536
|
+
return true;
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function recordConfigMigrationNotice(configPath: string, notice: string) {
|
|
544
|
+
if (!configMigrationNotices.has(configPath)) configMigrationNotices.set(configPath, notice);
|
|
545
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -7,6 +7,16 @@ import {
|
|
|
7
7
|
type ExtensionContext,
|
|
8
8
|
getAgentDir,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
activeLocalConfigPath,
|
|
12
|
+
consumeLocalConfigMigrationNotice,
|
|
13
|
+
legacyLocalConfigPath,
|
|
14
|
+
localConfigPath,
|
|
15
|
+
quarantineAndRemoveConfigIfMatches,
|
|
16
|
+
readMigratingLocalConfigDocument,
|
|
17
|
+
replaceLocalConfigDocument,
|
|
18
|
+
withLocalConfigFileLock,
|
|
19
|
+
} from "./config-file.js";
|
|
10
20
|
import { safeName } from "./paths.js";
|
|
11
21
|
import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
|
|
12
22
|
import type {
|
|
@@ -25,6 +35,15 @@ const DEFAULT_PREFIX = "pi-sync";
|
|
|
25
35
|
const DEFAULT_REGION = "auto";
|
|
26
36
|
export const DEFAULT_TARGET_SWITCH_ACTION: TargetSwitchAction = "ask";
|
|
27
37
|
|
|
38
|
+
export {
|
|
39
|
+
activeLocalConfigPath,
|
|
40
|
+
consumeLocalConfigMigrationNotice,
|
|
41
|
+
legacyLocalConfigPath,
|
|
42
|
+
localConfigPath,
|
|
43
|
+
quarantineAndRemoveConfigIfMatches,
|
|
44
|
+
replaceLocalConfigDocument,
|
|
45
|
+
};
|
|
46
|
+
|
|
28
47
|
export const DEPRECATED_PI_SYNC_ENV_NAMES = [
|
|
29
48
|
"PI_SYNC_ENDPOINT",
|
|
30
49
|
"PI_SYNC_BUCKET",
|
|
@@ -491,10 +510,6 @@ export function stateDir() {
|
|
|
491
510
|
return path.join(agentDir(), ".pisync");
|
|
492
511
|
}
|
|
493
512
|
|
|
494
|
-
export function localConfigPath() {
|
|
495
|
-
return path.join(agentDir(), "pi-sync.local.json");
|
|
496
|
-
}
|
|
497
|
-
|
|
498
513
|
export function localConfigTemplate(): Record<string, unknown> {
|
|
499
514
|
return {
|
|
500
515
|
version: 2,
|
|
@@ -524,22 +539,91 @@ export function localConfigTemplate(): Record<string, unknown> {
|
|
|
524
539
|
};
|
|
525
540
|
}
|
|
526
541
|
|
|
542
|
+
export async function readLocalConfigDocument() {
|
|
543
|
+
return readMigratingLocalConfigDocument(validateConfigDocumentForMigration);
|
|
544
|
+
}
|
|
545
|
+
|
|
527
546
|
export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
547
|
+
return (await readLocalConfigDocument())?.parsed;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function validateConfigDocumentForMigration(settings: Record<string, unknown>) {
|
|
551
|
+
if (!isV2SettingsObject(settings)) {
|
|
552
|
+
for (const field of [
|
|
553
|
+
"endpoint",
|
|
554
|
+
"bucket",
|
|
555
|
+
"region",
|
|
556
|
+
"accessKeyId",
|
|
557
|
+
"secretAccessKey",
|
|
558
|
+
"sessionToken",
|
|
559
|
+
"profile",
|
|
560
|
+
"prefix",
|
|
561
|
+
] as const) {
|
|
562
|
+
asOptionalString(settings[field]);
|
|
563
|
+
}
|
|
564
|
+
for (const field of ["autoSync", "syncSessions"] as const) {
|
|
565
|
+
const value = settings[field];
|
|
566
|
+
if (value !== undefined && typeof value !== "boolean" && typeof value !== "string") {
|
|
567
|
+
throw new Error(`Invalid pi-sync settings: ${field} must be a boolean or string.`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
normalizeSyncFiles(settings.syncFiles);
|
|
571
|
+
normalizeExtraFiles(settings.extraFiles);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
normalizeTargetSwitchAction(settings.targetSwitchAction);
|
|
576
|
+
const profiles = requireNamedObjectMap(settings.profiles, "profiles");
|
|
577
|
+
const targets = requireNamedObjectMap(settings.targets, "targets");
|
|
578
|
+
validateUniqueRemoteTargets(targets);
|
|
579
|
+
for (const [name, value] of Object.entries(profiles)) {
|
|
580
|
+
const profile = ownObject(profiles, name);
|
|
581
|
+
if (!profile || value !== profile) {
|
|
582
|
+
throw new Error(`Invalid pi-sync settings: storage profile "${name}" must be an object.`);
|
|
583
|
+
}
|
|
584
|
+
const kind = asOptionalString(profile.kind);
|
|
585
|
+
if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`Invalid pi-sync settings: profile "${name}" has unsupported kind "${kind}".`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
for (const field of [
|
|
591
|
+
"endpoint",
|
|
592
|
+
"region",
|
|
593
|
+
"accessKeyId",
|
|
594
|
+
"secretAccessKey",
|
|
595
|
+
"sessionToken",
|
|
596
|
+
] as const) {
|
|
597
|
+
asOptionalString(profile[field]);
|
|
598
|
+
}
|
|
537
599
|
}
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
600
|
+
for (const [name, value] of Object.entries(targets)) {
|
|
601
|
+
const target = ownObject(targets, name);
|
|
602
|
+
if (!target || value !== target) {
|
|
603
|
+
throw new Error(`Invalid pi-sync settings: target "${name}" must be an object.`);
|
|
604
|
+
}
|
|
605
|
+
const profileName = normalizeOptionalString(asOptionalString(target.profile));
|
|
606
|
+
if (!profileName || !Object.hasOwn(profiles, profileName)) {
|
|
607
|
+
throw new Error(`Invalid pi-sync settings: target "${name}" references a missing profile.`);
|
|
608
|
+
}
|
|
609
|
+
for (const field of ["bucket", "prefix", "namespace"] as const) {
|
|
610
|
+
asOptionalString(target[field]);
|
|
611
|
+
}
|
|
612
|
+
asOptionalBoolean(target.autoSync);
|
|
613
|
+
asOptionalBoolean(target.syncSessions);
|
|
614
|
+
normalizeSyncFiles(target.syncFiles);
|
|
615
|
+
normalizeExtraFiles(target.extraFiles);
|
|
616
|
+
}
|
|
617
|
+
const activeTarget = normalizeOptionalString(asOptionalString(settings.activeTarget));
|
|
618
|
+
if (Object.keys(targets).length === 0) {
|
|
619
|
+
if (activeTarget) {
|
|
620
|
+
throw new Error("Invalid pi-sync settings: targetless settings cannot have activeTarget.");
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (!activeTarget || !Object.hasOwn(targets, activeTarget)) {
|
|
625
|
+
throw new Error("Invalid pi-sync settings: activeTarget must reference an existing target.");
|
|
541
626
|
}
|
|
542
|
-
return parsed as Record<string, unknown>;
|
|
543
627
|
}
|
|
544
628
|
|
|
545
629
|
let configUpdateQueue: Promise<void> = Promise.resolve();
|
|
@@ -564,7 +648,11 @@ async function performLocalConfigUpdate(
|
|
|
564
648
|
return next;
|
|
565
649
|
}
|
|
566
650
|
|
|
567
|
-
export
|
|
651
|
+
export function writeLocalConfigObject(value: Record<string, unknown>) {
|
|
652
|
+
return withLocalConfigFileLock(() => writeLocalConfigObjectUnlocked(value));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function writeLocalConfigObjectUnlocked(value: Record<string, unknown>) {
|
|
568
656
|
const configPath = localConfigPath();
|
|
569
657
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
570
658
|
try {
|
package/src/manager-ui.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { setSyncTargetCompletions } from "./command.js";
|
|
3
3
|
import {
|
|
4
|
+
activeLocalConfigPath,
|
|
4
5
|
configuredTargetNames,
|
|
5
6
|
deprecatedPiSyncEnvironmentWarnings,
|
|
6
7
|
isCloudflareR2Endpoint,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
localConfigPath,
|
|
11
12
|
normalizeSyncFiles,
|
|
12
13
|
readLocalConfigObject,
|
|
14
|
+
readStateForConfig,
|
|
13
15
|
} from "./config.js";
|
|
14
16
|
import { inspectLock, isStaleLock } from "./lock.js";
|
|
15
17
|
import {
|
|
@@ -28,18 +30,25 @@ import { type TargetPullOutcome, useSyncTarget } from "./target-switch.js";
|
|
|
28
30
|
import type { SyncConfig } from "./types.js";
|
|
29
31
|
|
|
30
32
|
export const MAIN_MENU_ACTIONS = [
|
|
31
|
-
"Sync now",
|
|
33
|
+
"Sync now (recommended)",
|
|
34
|
+
"Pull from remote",
|
|
35
|
+
"Push to remote",
|
|
32
36
|
"Switch target",
|
|
33
37
|
"Status & changes",
|
|
34
38
|
"Settings",
|
|
39
|
+
"More…",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
const MORE_MENU_ACTIONS = [
|
|
35
43
|
"Manage targets & storage",
|
|
36
44
|
"History & recovery",
|
|
37
45
|
"Help",
|
|
46
|
+
"Back",
|
|
38
47
|
] as const;
|
|
39
|
-
|
|
40
48
|
const BACK = "Back";
|
|
41
49
|
|
|
42
50
|
type MainMenuAction = (typeof MAIN_MENU_ACTIONS)[number];
|
|
51
|
+
type ContextualMenuAction = "Manage targets & storage" | "History & recovery" | "Help";
|
|
43
52
|
type RunRoute = (
|
|
44
53
|
route: string,
|
|
45
54
|
signal?: AbortSignal,
|
|
@@ -59,10 +68,34 @@ export async function showSyncManager(
|
|
|
59
68
|
const state = await describeManagerState();
|
|
60
69
|
const selected = await ctx.ui.select(state.title, state.actions);
|
|
61
70
|
if (!selected) return;
|
|
62
|
-
switch (
|
|
63
|
-
|
|
71
|
+
switch (
|
|
72
|
+
selected as MainMenuAction | ContextualMenuAction | "Set up sync" | "Use existing settings"
|
|
73
|
+
) {
|
|
74
|
+
case "Sync now (recommended)":
|
|
64
75
|
await runCancellableOperation(ctx, "Checking current target…", "sync", runRoute, true);
|
|
65
76
|
break;
|
|
77
|
+
case "Pull from remote": {
|
|
78
|
+
const result = await runCancellableOperation(
|
|
79
|
+
ctx,
|
|
80
|
+
"Checking remote changes…",
|
|
81
|
+
"pull",
|
|
82
|
+
runRoute,
|
|
83
|
+
true,
|
|
84
|
+
"Pull check cancelled; no local files were changed.",
|
|
85
|
+
);
|
|
86
|
+
if (result === "applied") return;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "Push to remote":
|
|
90
|
+
await runCancellableOperation(
|
|
91
|
+
ctx,
|
|
92
|
+
"Preparing push preview…",
|
|
93
|
+
"push",
|
|
94
|
+
runRoute,
|
|
95
|
+
true,
|
|
96
|
+
"Push preparation cancelled; no remote files were changed.",
|
|
97
|
+
);
|
|
98
|
+
break;
|
|
66
99
|
case "Switch target": {
|
|
67
100
|
const result = await showTargetSwitcher(ctx, runRoute);
|
|
68
101
|
if (result === "pull-attempted") return;
|
|
@@ -75,23 +108,37 @@ export async function showSyncManager(
|
|
|
75
108
|
case "Settings":
|
|
76
109
|
await showSyncSettings(ctx, runRoute);
|
|
77
110
|
break;
|
|
111
|
+
case "More…":
|
|
112
|
+
if ((await showMoreMenu(ctx, runRoute)) === "exit") return;
|
|
113
|
+
break;
|
|
78
114
|
case "Manage targets & storage":
|
|
79
115
|
await showManageMenu(ctx, runRoute);
|
|
80
116
|
break;
|
|
81
117
|
case "History & recovery":
|
|
82
118
|
await showRecoveryMenu(ctx, runRoute);
|
|
83
119
|
break;
|
|
120
|
+
case "Help":
|
|
121
|
+
await runRoute("help");
|
|
122
|
+
return;
|
|
84
123
|
case "Set up sync":
|
|
85
124
|
case "Use existing settings":
|
|
86
125
|
await runRoute("init");
|
|
87
126
|
continue;
|
|
88
|
-
case "Help":
|
|
89
|
-
await runRoute("help");
|
|
90
|
-
return;
|
|
91
127
|
}
|
|
92
128
|
}
|
|
93
129
|
}
|
|
94
130
|
|
|
131
|
+
async function showMoreMenu(ctx: ExtensionCommandContext, runRoute: RunRoute) {
|
|
132
|
+
const selected = await ctx.ui.select("More options", [...MORE_MENU_ACTIONS]);
|
|
133
|
+
if (!selected || selected === BACK) return;
|
|
134
|
+
if (selected === "Manage targets & storage") await showManageMenu(ctx, runRoute);
|
|
135
|
+
else if (selected === "History & recovery") await showRecoveryMenu(ctx, runRoute);
|
|
136
|
+
else {
|
|
137
|
+
await runRoute("help");
|
|
138
|
+
return "exit" as const;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
95
142
|
async function runCancellableOperation(
|
|
96
143
|
ctx: ExtensionCommandContext,
|
|
97
144
|
message: string,
|
|
@@ -158,7 +205,7 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
158
205
|
"",
|
|
159
206
|
"Settings file needs repair. Automatic sync and settings writes are paused.",
|
|
160
207
|
`Error: ${safeTerminalText(errorMessage(error))}`,
|
|
161
|
-
`File: ${safeTerminalText(
|
|
208
|
+
`File: ${safeTerminalText(await activeLocalConfigPath())}`,
|
|
162
209
|
"",
|
|
163
210
|
"Repair the JSON file, then reopen /sync.",
|
|
164
211
|
].join("\n"),
|
|
@@ -194,15 +241,34 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
194
241
|
const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
|
|
195
242
|
const recoverableLock =
|
|
196
243
|
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
|
|
244
|
+
const builtInCount = normalizeSyncFiles(config.syncFiles).length;
|
|
245
|
+
const extraFileCount = config.extraFiles.length;
|
|
246
|
+
const noSyncedContent = builtInCount === 0 && extraFileCount === 0 && !config.syncSessions;
|
|
247
|
+
const stateReadDisabled = liveLock || recoverableLock;
|
|
248
|
+
const syncState = stateReadDisabled
|
|
249
|
+
? undefined
|
|
250
|
+
: await readStateForConfig(config).catch(() => undefined);
|
|
251
|
+
const lastAppliedSnapshot = stateReadDisabled
|
|
252
|
+
? "Unavailable while operations are locked"
|
|
253
|
+
: syncState?.lastAppliedSnapshot
|
|
254
|
+
? safeTerminalText(syncState.lastAppliedSnapshot)
|
|
255
|
+
: syncState
|
|
256
|
+
? "Never synced"
|
|
257
|
+
: "Unavailable";
|
|
197
258
|
return {
|
|
198
259
|
title: [
|
|
199
260
|
"Pi Sync",
|
|
200
261
|
"",
|
|
201
262
|
`Current target: ${safeTerminalText(target)}`,
|
|
202
263
|
`Storage: ${storage}`,
|
|
203
|
-
`
|
|
204
|
-
|
|
264
|
+
`Synced content: ${builtInCount} built-in group${builtInCount === 1 ? "" : "s"} · ${extraFileCount} extra file${extraFileCount === 1 ? "" : "s"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
|
|
265
|
+
`Auto-sync: ${config.autoSync ? "On" : "Off"}`,
|
|
266
|
+
`Last applied snapshot: ${lastAppliedSnapshot}`,
|
|
267
|
+
"Remote changes: Not checked",
|
|
205
268
|
...warnings.map((warning) => `Warning: ${safeTerminalText(warning)}`),
|
|
269
|
+
...(noSyncedContent
|
|
270
|
+
? ["", "No synced content is selected. Choose synced content in Settings before syncing."]
|
|
271
|
+
: []),
|
|
206
272
|
...(liveLock
|
|
207
273
|
? [
|
|
208
274
|
"",
|
|
@@ -218,7 +284,9 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
218
284
|
actions:
|
|
219
285
|
liveLock || recoverableLock
|
|
220
286
|
? ["Status & changes", "History & recovery", "Help"]
|
|
221
|
-
:
|
|
287
|
+
: noSyncedContent
|
|
288
|
+
? ["Settings", "Switch target", "Status & changes", "More…"]
|
|
289
|
+
: [...MAIN_MENU_ACTIONS],
|
|
222
290
|
};
|
|
223
291
|
} catch (error) {
|
|
224
292
|
const targets = ownRecord(raw.targets);
|
|
@@ -229,7 +297,7 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
229
297
|
"Settings need attention. Automatic sync is paused.",
|
|
230
298
|
`Current target: ${safeTerminalText(typeof raw.activeTarget === "string" ? raw.activeTarget : "default")}`,
|
|
231
299
|
`Error: ${safeTerminalText(errorMessage(error))}`,
|
|
232
|
-
`File: ${safeTerminalText(
|
|
300
|
+
`File: ${safeTerminalText(await activeLocalConfigPath())}`,
|
|
233
301
|
"",
|
|
234
302
|
"What do you want to do?",
|
|
235
303
|
].join("\n"),
|
package/src/paths.ts
CHANGED
|
@@ -14,7 +14,12 @@ export function isDeniedPath(relativePath: string) {
|
|
|
14
14
|
base.endsWith(".env") ||
|
|
15
15
|
base.includes("secret") ||
|
|
16
16
|
base.includes("token") ||
|
|
17
|
-
base === "pi-sync.
|
|
17
|
+
base === "pi-sync.json" ||
|
|
18
|
+
base.startsWith("pi-sync.json.") ||
|
|
19
|
+
base.startsWith(".pi-sync.json.") ||
|
|
20
|
+
base === "pi-sync.local.json" ||
|
|
21
|
+
base.startsWith("pi-sync.local.json.") ||
|
|
22
|
+
base.startsWith(".pi-sync.local.json.")
|
|
18
23
|
);
|
|
19
24
|
}
|
|
20
25
|
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
DEFAULT_TARGET_SWITCH_ACTION,
|
|
6
6
|
effectiveTargetRemoteIdentity,
|
|
7
7
|
localConfigPath,
|
|
8
|
+
readLocalConfigDocument,
|
|
8
9
|
readLocalConfigObject,
|
|
10
|
+
replaceLocalConfigDocument,
|
|
9
11
|
resolveLegacyPartialConfig,
|
|
10
12
|
resolveV2PartialConfig,
|
|
11
13
|
stateDir,
|
|
@@ -15,6 +17,21 @@ import {
|
|
|
15
17
|
import { withLock } from "./lock.js";
|
|
16
18
|
import type { PartialConfig, StorageProfileSettings, SyncTargetSettings } from "./types.js";
|
|
17
19
|
|
|
20
|
+
let afterLegacySettingsReadHook: () => Promise<void> = async () => undefined;
|
|
21
|
+
|
|
22
|
+
export async function withLegacySettingsReadHookForTest<T>(
|
|
23
|
+
hook: () => Promise<void>,
|
|
24
|
+
run: () => Promise<T>,
|
|
25
|
+
) {
|
|
26
|
+
const previous = afterLegacySettingsReadHook;
|
|
27
|
+
afterLegacySettingsReadHook = hook;
|
|
28
|
+
try {
|
|
29
|
+
return await run();
|
|
30
|
+
} finally {
|
|
31
|
+
afterLegacySettingsReadHook = previous;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
const LEGACY_FIELDS = new Set([
|
|
19
36
|
"endpoint",
|
|
20
37
|
"bucket",
|
|
@@ -42,21 +59,17 @@ export async function migrateLegacySettings(
|
|
|
42
59
|
validateName(targetName, "target");
|
|
43
60
|
validateName(storageProfileName, "storage profile");
|
|
44
61
|
return withLock("settings-migration", async () => {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const current =
|
|
48
|
-
if (!current) throw new Error("No legacy pi-sync settings are available to migrate.");
|
|
62
|
+
const document = await readLocalConfigDocument();
|
|
63
|
+
if (!document) throw new Error("No legacy pi-sync settings are available to migrate.");
|
|
64
|
+
const { bytes: originalBytes, parsed: current } = document;
|
|
49
65
|
if (current.version === 2) {
|
|
50
66
|
throw new Error("pi-sync settings already use profiles and targets.");
|
|
51
67
|
}
|
|
68
|
+
await afterLegacySettingsReadHook();
|
|
52
69
|
const next = legacySettingsAsV2(current, targetName, storageProfileName);
|
|
53
70
|
const backupPath = await writeMigrationBackup(originalBytes);
|
|
54
71
|
await adoptLegacyState(current, next, targetName);
|
|
55
|
-
|
|
56
|
-
if (!latestBytes.equals(originalBytes)) {
|
|
57
|
-
throw new Error("pi-sync settings changed during migration; no settings were replaced.");
|
|
58
|
-
}
|
|
59
|
-
await writeLocalConfigObject(next);
|
|
72
|
+
await replaceLocalConfigDocument(document, next);
|
|
60
73
|
return { settings: next, backupPath };
|
|
61
74
|
});
|
|
62
75
|
}
|
package/src/sync.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { promisify } from "node:util";
|
|
@@ -17,7 +16,9 @@ import {
|
|
|
17
16
|
validateCommandOptions,
|
|
18
17
|
} from "./command.js";
|
|
19
18
|
import {
|
|
19
|
+
activeLocalConfigPath,
|
|
20
20
|
configuredTargetNames,
|
|
21
|
+
consumeLocalConfigMigrationNotice,
|
|
21
22
|
deprecatedPiSyncEnvironmentWarnings,
|
|
22
23
|
ensureStateDir,
|
|
23
24
|
isEnabled,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
localConfigTemplate,
|
|
30
31
|
normalizeExtraFiles,
|
|
31
32
|
normalizeSyncFiles,
|
|
33
|
+
readLocalConfigObject,
|
|
32
34
|
readStateForConfig,
|
|
33
35
|
sessionDirForApply,
|
|
34
36
|
sessionTokenWarnings,
|
|
@@ -142,6 +144,8 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
142
144
|
} catch {
|
|
143
145
|
setSyncTargetCompletions([]);
|
|
144
146
|
}
|
|
147
|
+
const migrationNotice = consumeLocalConfigMigrationNotice();
|
|
148
|
+
if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
|
|
145
149
|
const warnings = deprecatedPiSyncEnvironmentWarnings();
|
|
146
150
|
if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
|
|
147
151
|
await autoSync(ctx);
|
|
@@ -279,12 +283,9 @@ async function autoPushSessions(ctx: ExtensionContext) {
|
|
|
279
283
|
|
|
280
284
|
async function initConfig(ctx: ExtensionCommandContext) {
|
|
281
285
|
const configPath = localConfigPath();
|
|
282
|
-
|
|
283
|
-
await
|
|
284
|
-
ctx.ui.notify(`Config already exists: ${configPath}`, "info");
|
|
286
|
+
if (await readLocalConfigObject()) {
|
|
287
|
+
ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
|
|
285
288
|
return;
|
|
286
|
-
} catch {
|
|
287
|
-
// Create or guide setup below.
|
|
288
289
|
}
|
|
289
290
|
|
|
290
291
|
if (ctx.mode === "tui") {
|