@narumitw/pi-sync 0.30.0 → 0.34.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 +159 -236
- package/package.json +14 -6
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/paths.ts +4 -1
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/webdav-ui.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { promptSecret } from "./secret-input.js";
|
|
3
|
+
import {
|
|
4
|
+
addStorageConnection,
|
|
5
|
+
addSyncSetup,
|
|
6
|
+
saveNewV3Settings,
|
|
7
|
+
updateStorageConnection,
|
|
8
|
+
updateSyncSetup,
|
|
9
|
+
} from "./settings-management.js";
|
|
10
|
+
import { DEFAULT_SYNC_INCLUDE } from "./sync-policy.js";
|
|
11
|
+
import type { PartialConfig } from "./types.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeWebDavPath,
|
|
14
|
+
normalizeWebDavUrl,
|
|
15
|
+
validateWebDavCredentials,
|
|
16
|
+
validateWebDavNamespace,
|
|
17
|
+
} from "./webdav-config.js";
|
|
18
|
+
|
|
19
|
+
export async function showWebDavSetup(
|
|
20
|
+
ctx: ExtensionCommandContext,
|
|
21
|
+
targetName: string,
|
|
22
|
+
signal?: AbortSignal,
|
|
23
|
+
) {
|
|
24
|
+
const url = await requiredInput(
|
|
25
|
+
ctx,
|
|
26
|
+
"WebDAV collection URL",
|
|
27
|
+
"https://cloud.example.com/remote.php/dav/files/user",
|
|
28
|
+
signal,
|
|
29
|
+
);
|
|
30
|
+
if (!url) return false;
|
|
31
|
+
const username = await requiredInput(ctx, "WebDAV username", "user", signal);
|
|
32
|
+
if (!username) return false;
|
|
33
|
+
const password = await awaitActive(signal, promptSecret(ctx, "WebDAV password", { signal }));
|
|
34
|
+
if (password === undefined) return false;
|
|
35
|
+
const location = await chooseDestination(ctx, targetName, signal);
|
|
36
|
+
if (!location) return false;
|
|
37
|
+
const connection = validateConnection(ctx, url, username, password);
|
|
38
|
+
const destination = validateDestination(ctx, location.path);
|
|
39
|
+
if (!connection || !destination) return false;
|
|
40
|
+
const content = await chooseContent(ctx, signal);
|
|
41
|
+
if (!content) return false;
|
|
42
|
+
const automatic = await select(
|
|
43
|
+
ctx,
|
|
44
|
+
"Automatic sync for this setup",
|
|
45
|
+
["Enable automatic sync", "Keep automatic sync off", "Cancel"],
|
|
46
|
+
signal,
|
|
47
|
+
);
|
|
48
|
+
if (!automatic || automatic === "Cancel") return false;
|
|
49
|
+
const sessions = await chooseSessions(ctx, signal);
|
|
50
|
+
if (sessions === undefined) return false;
|
|
51
|
+
const profileName = "webdav";
|
|
52
|
+
const review = await select(
|
|
53
|
+
ctx,
|
|
54
|
+
[
|
|
55
|
+
"Review WebDAV setup",
|
|
56
|
+
"",
|
|
57
|
+
`Sync setup: ${safe(targetName)}`,
|
|
58
|
+
`Storage connection: ${profileName} (WebDAV)`,
|
|
59
|
+
`URL: ${displayUrl(connection.url)}`,
|
|
60
|
+
`Storage location: ${safe(destination.path)}`,
|
|
61
|
+
"Username: stored in the private settings file (value hidden)",
|
|
62
|
+
"Password: configured (value hidden)",
|
|
63
|
+
`Conditional writes: /sync doctor verifies atomic If-Match and If-None-Match support before publication.`,
|
|
64
|
+
`Included content: ${content.length} built-in groups · Sessions: ${sessions ? "On — privacy warning acknowledged" : "Off"}`,
|
|
65
|
+
`Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}`,
|
|
66
|
+
].join("\n"),
|
|
67
|
+
["Save setup", "Cancel"],
|
|
68
|
+
signal,
|
|
69
|
+
);
|
|
70
|
+
if (review !== "Save setup") return false;
|
|
71
|
+
throwIfAborted(signal);
|
|
72
|
+
await awaitActive(
|
|
73
|
+
signal,
|
|
74
|
+
saveNewV3Settings(
|
|
75
|
+
{
|
|
76
|
+
setupName: targetName,
|
|
77
|
+
connectionName: profileName,
|
|
78
|
+
connection: {
|
|
79
|
+
type: "webdav",
|
|
80
|
+
url: connection.url,
|
|
81
|
+
credentials: { username: connection.username, password: connection.password ?? "" },
|
|
82
|
+
},
|
|
83
|
+
setup: {
|
|
84
|
+
storage: { connection: profileName, path: destination.path },
|
|
85
|
+
sync: {
|
|
86
|
+
include: [...content, ...(sessions ? ["sessions"] : [])],
|
|
87
|
+
automatic: automatic === "Enable automatic sync",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
signal,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
ctx.ui.notify(`Sync setup “${safe(targetName)}” is ready. Use Sync now when ready.`, "info");
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function showAddWebDavTarget(
|
|
99
|
+
ctx: ExtensionCommandContext,
|
|
100
|
+
name: string,
|
|
101
|
+
profile: string,
|
|
102
|
+
signal?: AbortSignal,
|
|
103
|
+
) {
|
|
104
|
+
const location = await chooseDestination(ctx, name, signal);
|
|
105
|
+
if (!location) return false;
|
|
106
|
+
const destination = validateDestination(ctx, location.path);
|
|
107
|
+
if (!destination) return false;
|
|
108
|
+
const content = await chooseContent(ctx, signal);
|
|
109
|
+
if (!content) return false;
|
|
110
|
+
const review = await select(
|
|
111
|
+
ctx,
|
|
112
|
+
`Review WebDAV sync setup\n\nSync setup: ${safe(name)}\nStorage connection: ${safe(profile)}\nStorage location: ${safe(destination.path)}\nIncluded content: ${content.length} built-in groups · Sessions: Off\nAdding this setup does not sync or modify remote data.`,
|
|
113
|
+
["Add sync setup", "Cancel"],
|
|
114
|
+
signal,
|
|
115
|
+
);
|
|
116
|
+
if (review !== "Add sync setup") return false;
|
|
117
|
+
throwIfAborted(signal);
|
|
118
|
+
await awaitActive(
|
|
119
|
+
signal,
|
|
120
|
+
addSyncSetup(
|
|
121
|
+
name,
|
|
122
|
+
{
|
|
123
|
+
storage: { connection: profile, path: destination.path },
|
|
124
|
+
sync: { include: content, automatic: true },
|
|
125
|
+
},
|
|
126
|
+
signal,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
ctx.ui.notify(`Added sync setup “${safe(name)}”.`, "info");
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function showEditWebDavTarget(
|
|
134
|
+
ctx: ExtensionCommandContext,
|
|
135
|
+
partial: PartialConfig,
|
|
136
|
+
signal?: AbortSignal,
|
|
137
|
+
) {
|
|
138
|
+
const remotePath = await requiredInput(ctx, "WebDAV storage path", partial.storagePath, signal);
|
|
139
|
+
if (!remotePath) return false;
|
|
140
|
+
const destination = validateDestination(ctx, remotePath);
|
|
141
|
+
if (!destination) return false;
|
|
142
|
+
const review = await select(
|
|
143
|
+
ctx,
|
|
144
|
+
`Review sync setup “${safe(partial.setupName)}”\n\nStorage path: ${safe(partial.storagePath)} → ${safe(destination.path)}\nSaving changes the future storage location only; it does not move or delete remote data.`,
|
|
145
|
+
["Save sync setup", "Cancel"],
|
|
146
|
+
signal,
|
|
147
|
+
);
|
|
148
|
+
if (review !== "Save sync setup") return false;
|
|
149
|
+
throwIfAborted(signal);
|
|
150
|
+
await awaitActive(
|
|
151
|
+
signal,
|
|
152
|
+
updateSyncSetup(
|
|
153
|
+
partial.setupName,
|
|
154
|
+
(setup) => ({
|
|
155
|
+
...setup,
|
|
156
|
+
storage: { ...setup.storage, path: destination.path },
|
|
157
|
+
}),
|
|
158
|
+
{ expectedStorage: partial, signal },
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
ctx.ui.notify(`Saved sync setup “${safe(partial.setupName)}”.`, "info");
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function showAddWebDavStorageProfile(
|
|
166
|
+
ctx: ExtensionCommandContext,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
) {
|
|
169
|
+
const name = await requiredInput(ctx, "Name this storage connection", "webdav", signal);
|
|
170
|
+
if (!name) return false;
|
|
171
|
+
const url = await requiredInput(
|
|
172
|
+
ctx,
|
|
173
|
+
"WebDAV collection URL",
|
|
174
|
+
"https://cloud.example.com/dav",
|
|
175
|
+
signal,
|
|
176
|
+
);
|
|
177
|
+
if (!url) return false;
|
|
178
|
+
const username = await requiredInput(ctx, "WebDAV username", "user", signal);
|
|
179
|
+
if (!username) return false;
|
|
180
|
+
const password = await awaitActive(signal, promptSecret(ctx, "WebDAV password", { signal }));
|
|
181
|
+
if (password === undefined) return false;
|
|
182
|
+
const connection = validateConnection(ctx, url, username, password);
|
|
183
|
+
if (!connection) return false;
|
|
184
|
+
const review = await select(
|
|
185
|
+
ctx,
|
|
186
|
+
`Review storage connection\n\nName: ${safe(name)}\nType: WebDAV\nURL: ${displayUrl(connection.url)}\nUsername: stored privately (value hidden)\nPassword: configured (value hidden)\nAdding a connection does not contact the server or start syncing.`,
|
|
187
|
+
["Add storage connection", "Cancel"],
|
|
188
|
+
signal,
|
|
189
|
+
);
|
|
190
|
+
if (review !== "Add storage connection") return false;
|
|
191
|
+
throwIfAborted(signal);
|
|
192
|
+
await awaitActive(
|
|
193
|
+
signal,
|
|
194
|
+
addStorageConnection(
|
|
195
|
+
name,
|
|
196
|
+
{
|
|
197
|
+
type: "webdav",
|
|
198
|
+
url: connection.url,
|
|
199
|
+
credentials: { username: connection.username, password: connection.password ?? "" },
|
|
200
|
+
},
|
|
201
|
+
signal,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
ctx.ui.notify(`Added storage connection “${safe(name)}”.`, "info");
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function showEditWebDavStorageProfile(
|
|
209
|
+
ctx: ExtensionCommandContext,
|
|
210
|
+
name: string,
|
|
211
|
+
profile: Record<string, unknown>,
|
|
212
|
+
signal?: AbortSignal,
|
|
213
|
+
affectedSetups?: string[],
|
|
214
|
+
) {
|
|
215
|
+
const url = await requiredInput(
|
|
216
|
+
ctx,
|
|
217
|
+
"WebDAV collection URL",
|
|
218
|
+
String(profile.url ?? "https://cloud.example.com/dav"),
|
|
219
|
+
signal,
|
|
220
|
+
);
|
|
221
|
+
if (!url) return false;
|
|
222
|
+
const username = await requiredInput(
|
|
223
|
+
ctx,
|
|
224
|
+
"WebDAV username",
|
|
225
|
+
String(profile.username ?? "user"),
|
|
226
|
+
signal,
|
|
227
|
+
);
|
|
228
|
+
if (!username) return false;
|
|
229
|
+
let password: string | undefined;
|
|
230
|
+
let replacePassword = false;
|
|
231
|
+
if (typeof profile.password === "string" && profile.password.length > 0) {
|
|
232
|
+
const passwordAction = await select(
|
|
233
|
+
ctx,
|
|
234
|
+
"WebDAV password",
|
|
235
|
+
["Keep current password", "Replace password", "Cancel"],
|
|
236
|
+
signal,
|
|
237
|
+
);
|
|
238
|
+
if (!passwordAction || passwordAction === "Cancel") return false;
|
|
239
|
+
replacePassword = passwordAction === "Replace password";
|
|
240
|
+
} else {
|
|
241
|
+
replacePassword = true;
|
|
242
|
+
}
|
|
243
|
+
if (replacePassword) {
|
|
244
|
+
password = await awaitActive(signal, promptSecret(ctx, "New WebDAV password", { signal }));
|
|
245
|
+
if (password === undefined) return false;
|
|
246
|
+
}
|
|
247
|
+
const connection = validateConnection(ctx, url, username, password);
|
|
248
|
+
if (!connection) return false;
|
|
249
|
+
const review = await select(
|
|
250
|
+
ctx,
|
|
251
|
+
`Review storage connection\n\nStorage connection: ${safe(name)}\nURL: ${displayUrl(String(profile.url ?? "https://invalid.invalid"))} → ${displayUrl(connection.url)}\nUsername: stored privately (value hidden)\nPassword: ${replacePassword ? "will be replaced" : "unchanged"} (value hidden)\nAffected sync setups: ${affectedSetups && affectedSetups.length > 0 ? affectedSetups.map(safe).join(", ") : "None"}\nSaving changes future storage access for every affected setup; it does not move remote data.`,
|
|
252
|
+
["Save storage connection", "Cancel"],
|
|
253
|
+
signal,
|
|
254
|
+
);
|
|
255
|
+
if (review !== "Save storage connection") return false;
|
|
256
|
+
throwIfAborted(signal);
|
|
257
|
+
await awaitActive(
|
|
258
|
+
signal,
|
|
259
|
+
updateStorageConnection(
|
|
260
|
+
name,
|
|
261
|
+
(current) => {
|
|
262
|
+
if (current.type !== "webdav") {
|
|
263
|
+
throw new Error("Storage connection type changed; reopen it.");
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...current,
|
|
267
|
+
url: connection.url,
|
|
268
|
+
credentials: {
|
|
269
|
+
...current.credentials,
|
|
270
|
+
username: connection.username,
|
|
271
|
+
password: replacePassword
|
|
272
|
+
? (connection.password ?? current.credentials.password)
|
|
273
|
+
: current.credentials.password,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
affectedSetups,
|
|
278
|
+
signal,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
ctx.ui.notify(`Saved storage connection “${safe(name)}”.`, "info");
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function chooseDestination(
|
|
286
|
+
ctx: ExtensionCommandContext,
|
|
287
|
+
targetName: string,
|
|
288
|
+
signal?: AbortSignal,
|
|
289
|
+
) {
|
|
290
|
+
const remotePath = await requiredInput(
|
|
291
|
+
ctx,
|
|
292
|
+
"WebDAV storage path",
|
|
293
|
+
`pi-sync/${targetName}`,
|
|
294
|
+
signal,
|
|
295
|
+
);
|
|
296
|
+
return remotePath ? validateDestination(ctx, remotePath) : undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function chooseContent(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
300
|
+
const choice = await select(
|
|
301
|
+
ctx,
|
|
302
|
+
"Choose an initial sync preset",
|
|
303
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
304
|
+
signal,
|
|
305
|
+
);
|
|
306
|
+
if (!choice || choice === "Cancel") return undefined;
|
|
307
|
+
return choice === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function chooseSessions(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
311
|
+
const choice = await select(
|
|
312
|
+
ctx,
|
|
313
|
+
"Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
314
|
+
["Keep sessions off (recommended)", "Include session conversations", "Cancel"],
|
|
315
|
+
signal,
|
|
316
|
+
);
|
|
317
|
+
if (!choice || choice === "Cancel") return undefined;
|
|
318
|
+
if (choice !== "Include session conversations") return false;
|
|
319
|
+
return confirm(
|
|
320
|
+
ctx,
|
|
321
|
+
"Include session conversations?",
|
|
322
|
+
"I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
323
|
+
signal,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function requiredInput(
|
|
328
|
+
ctx: ExtensionCommandContext,
|
|
329
|
+
title: string,
|
|
330
|
+
placeholder: string,
|
|
331
|
+
signal?: AbortSignal,
|
|
332
|
+
) {
|
|
333
|
+
const value = await awaitActive(signal, ctx.ui.input(title, placeholder, { signal }));
|
|
334
|
+
if (value === undefined) return undefined;
|
|
335
|
+
const trimmed = value.trim();
|
|
336
|
+
if (!trimmed) {
|
|
337
|
+
ctx.ui.notify(`${title} is required.`, "warning");
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
return trimmed;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function select(
|
|
344
|
+
ctx: ExtensionCommandContext,
|
|
345
|
+
title: string,
|
|
346
|
+
options: string[],
|
|
347
|
+
signal?: AbortSignal,
|
|
348
|
+
) {
|
|
349
|
+
return awaitActive(signal, ctx.ui.select(title, options, { signal }));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function confirm(
|
|
353
|
+
ctx: ExtensionCommandContext,
|
|
354
|
+
title: string,
|
|
355
|
+
message: string,
|
|
356
|
+
signal?: AbortSignal,
|
|
357
|
+
) {
|
|
358
|
+
return awaitActive(signal, ctx.ui.confirm(title, message, { signal }));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function awaitActive<T>(signal: AbortSignal | undefined, operation: Promise<T>) {
|
|
362
|
+
const result = await operation;
|
|
363
|
+
throwIfAborted(signal);
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
368
|
+
if (!signal?.aborted) return;
|
|
369
|
+
throw signal.reason instanceof Error
|
|
370
|
+
? signal.reason
|
|
371
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function validateConnection(
|
|
375
|
+
ctx: ExtensionCommandContext,
|
|
376
|
+
url: string,
|
|
377
|
+
username: string,
|
|
378
|
+
password?: string,
|
|
379
|
+
) {
|
|
380
|
+
try {
|
|
381
|
+
const normalizedUrl = normalizeWebDavUrl(url);
|
|
382
|
+
if (!normalizedUrl) throw new Error("WebDAV URL is required.");
|
|
383
|
+
validateWebDavCredentials(username, password);
|
|
384
|
+
return {
|
|
385
|
+
url: normalizedUrl,
|
|
386
|
+
username: username.trim(),
|
|
387
|
+
...(password === undefined ? {} : { password }),
|
|
388
|
+
};
|
|
389
|
+
} catch (error) {
|
|
390
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function validateDestination(ctx: ExtensionCommandContext, path: string, namespace?: string) {
|
|
396
|
+
try {
|
|
397
|
+
const basePath = normalizeWebDavPath(path);
|
|
398
|
+
const normalizedPath = namespace
|
|
399
|
+
? normalizeWebDavPath(`${basePath}/${namespace.trim()}`)
|
|
400
|
+
: basePath;
|
|
401
|
+
const resolvedNamespace = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
|
|
402
|
+
validateWebDavNamespace(resolvedNamespace);
|
|
403
|
+
return { path: normalizedPath, namespace: resolvedNamespace };
|
|
404
|
+
} catch (error) {
|
|
405
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function displayUrl(value: string) {
|
|
411
|
+
try {
|
|
412
|
+
return `${new URL(value).origin}/…`;
|
|
413
|
+
} catch {
|
|
414
|
+
return "invalid URL (value hidden)";
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function safe(value: string) {
|
|
419
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Settings values are untrusted terminal input.
|
|
420
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "�");
|
|
421
|
+
}
|
package/src/target-switch.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { setSyncTargetCompletions } from "./command.js";
|
|
3
|
-
import {
|
|
4
|
-
configuredTargetNames,
|
|
5
|
-
loadConfig,
|
|
6
|
-
loadTargetSwitchAction,
|
|
7
|
-
readLocalConfigObject,
|
|
8
|
-
updateLocalConfig,
|
|
9
|
-
} from "./config.js";
|
|
10
|
-
import { safeTerminalText } from "./sync-format.js";
|
|
11
|
-
import type { TargetSwitchAction } from "./types.js";
|
|
12
|
-
|
|
13
|
-
export const TARGET_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
|
|
14
|
-
label: string;
|
|
15
|
-
value: TargetSwitchAction;
|
|
16
|
-
}> = [
|
|
17
|
-
{ label: "Ask before pull", value: "ask" },
|
|
18
|
-
{ label: "Start pull", value: "pull" },
|
|
19
|
-
{ label: "Switch only", value: "switch-only" },
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
export function targetSwitchActionLabel(action: TargetSwitchAction) {
|
|
23
|
-
return TARGET_SWITCH_ACTION_OPTIONS.find((option) => option.value === action)?.label ?? action;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function targetSwitchActionFromLabel(label: string): TargetSwitchAction | undefined {
|
|
27
|
-
return TARGET_SWITCH_ACTION_OPTIONS.find((option) => option.label === label)?.value;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function saveTargetSwitchAction(action: TargetSwitchAction) {
|
|
31
|
-
await updateLocalConfig((settings) => {
|
|
32
|
-
if (settings.version !== 2) {
|
|
33
|
-
throw new Error("Target-switch settings require version 2 settings.");
|
|
34
|
-
}
|
|
35
|
-
return { ...settings, targetSwitchAction: action };
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type TargetPullOutcome = "applied" | "cancelled";
|
|
40
|
-
|
|
41
|
-
export class TargetPullRequiresUiError extends Error {}
|
|
42
|
-
|
|
43
|
-
export interface TargetSwitchResult {
|
|
44
|
-
pullApplied: boolean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function useSyncTarget(
|
|
48
|
-
ctx: ExtensionCommandContext,
|
|
49
|
-
name: string,
|
|
50
|
-
pullCurrentTarget?: (target: string) => Promise<TargetPullOutcome | undefined>,
|
|
51
|
-
): Promise<TargetSwitchResult> {
|
|
52
|
-
const normalized = name.trim();
|
|
53
|
-
if (!normalized) throw new Error("Usage: /sync use <target>");
|
|
54
|
-
await loadConfig(normalized);
|
|
55
|
-
const before = await readLocalConfigObject();
|
|
56
|
-
if (before?.version === 2 && before.activeTarget === normalized) {
|
|
57
|
-
ctx.ui.notify(`Target “${safeTerminalText(normalized)}” is already current.`, "info");
|
|
58
|
-
return { pullApplied: false };
|
|
59
|
-
}
|
|
60
|
-
const action = await loadTargetSwitchAction();
|
|
61
|
-
if (action === "pull" && !ctx.hasUI) {
|
|
62
|
-
throw new TargetPullRequiresUiError(
|
|
63
|
-
`Automatic target pulls require interactive confirmation; target “${safeTerminalText(normalized)}” was not switched. Use TUI or RPC mode.`,
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
let switched = false;
|
|
67
|
-
await updateLocalConfig((current) => {
|
|
68
|
-
if (current.version !== 2) throw new Error("Target switching requires version 2 settings.");
|
|
69
|
-
if (current.activeTarget === normalized) return current;
|
|
70
|
-
switched = true;
|
|
71
|
-
return { ...current, activeTarget: normalized };
|
|
72
|
-
});
|
|
73
|
-
if (!switched) {
|
|
74
|
-
ctx.ui.notify(`Target “${safeTerminalText(normalized)}” is already current.`, "info");
|
|
75
|
-
return { pullApplied: false };
|
|
76
|
-
}
|
|
77
|
-
setSyncTargetCompletions(await configuredTargetNames());
|
|
78
|
-
|
|
79
|
-
if (action === "switch-only") {
|
|
80
|
-
ctx.ui.notify(`Switched to “${safeTerminalText(normalized)}”. No files were pulled.`, "info");
|
|
81
|
-
return { pullApplied: false };
|
|
82
|
-
}
|
|
83
|
-
if (action === "ask") {
|
|
84
|
-
if (ctx.mode !== "tui") {
|
|
85
|
-
ctx.ui.notify(
|
|
86
|
-
`Switched to “${safeTerminalText(normalized)}”. No files were pulled because confirmation requires TUI mode; run /sync pull to apply the target.`,
|
|
87
|
-
"info",
|
|
88
|
-
);
|
|
89
|
-
return { pullApplied: false };
|
|
90
|
-
}
|
|
91
|
-
const confirmed = await ctx.ui.confirm(
|
|
92
|
-
`Review a pull for target “${safeTerminalText(normalized)}” now?`,
|
|
93
|
-
"pi-sync will check the remote snapshot and show the exact local writes and deletions before applying anything.",
|
|
94
|
-
);
|
|
95
|
-
if (!confirmed) {
|
|
96
|
-
ctx.ui.notify(
|
|
97
|
-
`Switched to “${safeTerminalText(normalized)}”; files were not pulled.`,
|
|
98
|
-
"info",
|
|
99
|
-
);
|
|
100
|
-
return { pullApplied: false };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
ctx.ui.notify(
|
|
105
|
-
`Switched to “${safeTerminalText(normalized)}”. Checking remote files for a reviewed pull…`,
|
|
106
|
-
"info",
|
|
107
|
-
);
|
|
108
|
-
if (!pullCurrentTarget) {
|
|
109
|
-
throw new Error(`Switched to “${safeTerminalText(normalized)}”, but pull is unavailable.`);
|
|
110
|
-
}
|
|
111
|
-
const pullOutcome = await pullCurrentTarget(normalized);
|
|
112
|
-
if (pullOutcome === "cancelled") {
|
|
113
|
-
ctx.ui.notify(
|
|
114
|
-
`Pull cancelled; target “${safeTerminalText(normalized)}” remains active and synced files were not changed.`,
|
|
115
|
-
"info",
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
return { pullApplied: pullOutcome === "applied" };
|
|
119
|
-
}
|