@wuyaos/pi-sync 1.1.0 → 1.2.1
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/PROMO.md +37 -73
- package/README.md +141 -132
- package/README.zh-CN.md +141 -132
- package/extensions/_shared/enhanced-select.ts +13 -0
- package/extensions/sync/archive.ts +86 -121
- package/extensions/sync/config.ts +57 -27
- package/extensions/sync/i18n.ts +203 -0
- package/extensions/sync/index.ts +20 -43
- package/extensions/sync/menus.ts +490 -171
- package/extensions/sync/restore.ts +113 -103
- package/extensions/sync/webdav.ts +33 -12
- package/package.json +14 -6
- package/pi-bootstrap.ps1 +33 -74
- package/docs/sync-menu.png +0 -0
- package/extensions/sync/session-sync.ts +0 -231
package/extensions/sync/menus.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
@@ -6,252 +6,571 @@ import { enhancedSelect } from "../_shared/enhanced-select";
|
|
|
6
6
|
import {
|
|
7
7
|
archiveTimestamp,
|
|
8
8
|
createAgentSkillsZip,
|
|
9
|
-
|
|
10
|
-
createLegacyZip,
|
|
11
|
-
createMemoryZip,
|
|
9
|
+
createPiAgentZip,
|
|
12
10
|
createSessionsArchiveZip,
|
|
13
11
|
listArchiveEntries,
|
|
14
12
|
platformTag,
|
|
15
13
|
validateArchiveEntries,
|
|
16
|
-
yieldToUI,
|
|
17
14
|
} from "./archive";
|
|
18
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
SESSIONS_DIR,
|
|
17
|
+
isProjectAllowed,
|
|
18
|
+
loadConfig,
|
|
19
|
+
normalizePiExcludePaths,
|
|
20
|
+
saveConfig,
|
|
21
|
+
type SyncConfig,
|
|
22
|
+
} from "./config";
|
|
23
|
+
import { t, type SyncLanguage } from "./i18n";
|
|
19
24
|
import {
|
|
20
25
|
extractAgentSkillsZip,
|
|
21
|
-
|
|
22
|
-
extractLegacyZip,
|
|
23
|
-
extractMemoryZip,
|
|
26
|
+
extractPiAgentZip,
|
|
24
27
|
extractSessionsArchiveZip,
|
|
25
28
|
getRestorePlan,
|
|
26
29
|
} from "./restore";
|
|
27
|
-
import {
|
|
28
|
-
describeSessionSelection,
|
|
29
|
-
listSessionProjects,
|
|
30
|
-
sessionDirToPath,
|
|
31
|
-
showSessionProjectSelect,
|
|
32
|
-
showSessionSyncMenu,
|
|
33
|
-
} from "./session-sync";
|
|
34
30
|
import {
|
|
35
31
|
downloadFromWebdavDir,
|
|
36
32
|
listWebdavDir,
|
|
37
33
|
pruneOldBackupsInDir,
|
|
38
|
-
sessionsWebdavBase,
|
|
39
34
|
uploadToWebdavDir,
|
|
40
|
-
WEBDAV_AGENT_SKILLS_DIR,
|
|
41
|
-
WEBDAV_CONFIG_DIR,
|
|
42
|
-
WEBDAV_MEMORY_DIR,
|
|
43
|
-
WEBDAV_SESSIONS_DIR,
|
|
44
35
|
webdavAuth,
|
|
45
|
-
|
|
36
|
+
webdavDirBase,
|
|
46
37
|
webdavList,
|
|
38
|
+
WEBDAV_AGENT_SKILLS_DIR,
|
|
39
|
+
WEBDAV_PI_BACKUP_DIR,
|
|
40
|
+
WEBDAV_SESSIONS_ARCHIVE_DIR,
|
|
47
41
|
} from "./webdav";
|
|
48
42
|
|
|
43
|
+
export type SelectItem<T extends string> = { id: T; label: string };
|
|
44
|
+
type BackupKind = "pi" | "skills";
|
|
45
|
+
export type MainAction =
|
|
46
|
+
| "upload-all" | "restore-all"
|
|
47
|
+
| "upload-pi" | "upload-skills" | "upload-sessions"
|
|
48
|
+
| "restore-pi" | "restore-skills" | "restore-sessions"
|
|
49
|
+
| "configure" | "language" | "cancel";
|
|
50
|
+
|
|
51
|
+
export interface BulkResult {
|
|
52
|
+
kind: "pi" | "skills" | "session";
|
|
53
|
+
project?: string;
|
|
54
|
+
success: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BackupAllOperations {
|
|
59
|
+
uploadPi(): Promise<boolean>;
|
|
60
|
+
uploadSkills(): Promise<boolean>;
|
|
61
|
+
listProjects(): string[];
|
|
62
|
+
uploadSession(project: string): Promise<boolean>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RestoreAllOperations {
|
|
66
|
+
restorePi(): Promise<boolean>;
|
|
67
|
+
restoreSkills(): Promise<boolean>;
|
|
68
|
+
listProjects(): Promise<string[]>;
|
|
69
|
+
restoreSession(project: string): Promise<boolean>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function selectAction<T extends string>(
|
|
73
|
+
ctx: ExtensionCommandContext,
|
|
74
|
+
title: string,
|
|
75
|
+
items: SelectItem<T>[],
|
|
76
|
+
initialIndex = 0,
|
|
77
|
+
): Promise<T | undefined> {
|
|
78
|
+
const selected = await enhancedSelect(ctx, title, items.map((item) => item.label), { initialIndex });
|
|
79
|
+
return items.find((item) => item.label === selected)?.id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cloneConfig(config: SyncConfig): SyncConfig {
|
|
83
|
+
return {
|
|
84
|
+
...config,
|
|
85
|
+
piExcludePaths: [...config.piExcludePaths],
|
|
86
|
+
sessionProjects: [...config.sessionProjects],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onOff(language: SyncLanguage, value: boolean): string {
|
|
91
|
+
return t(language, value ? "on" : "off");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function kindLabel(language: SyncLanguage, kind: BackupKind): string {
|
|
95
|
+
return t(language, kind === "pi" ? "piKind" : "skillsKind");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildMainMenuItems(language: SyncLanguage): SelectItem<MainAction>[] {
|
|
99
|
+
return [
|
|
100
|
+
{ id: "upload-all", label: t(language, "uploadAllBackups") },
|
|
101
|
+
{ id: "restore-all", label: t(language, "restoreAllBackups") },
|
|
102
|
+
{ id: "upload-pi", label: t(language, "uploadPiBackup") },
|
|
103
|
+
{ id: "upload-skills", label: t(language, "uploadSkillsBackup") },
|
|
104
|
+
{ id: "upload-sessions", label: t(language, "uploadSessionsArchive") },
|
|
105
|
+
{ id: "restore-pi", label: t(language, "restorePiBackup") },
|
|
106
|
+
{ id: "restore-skills", label: t(language, "restoreSkillsBackup") },
|
|
107
|
+
{ id: "restore-sessions", label: t(language, "restoreSessionsArchive") },
|
|
108
|
+
{ id: "configure", label: t(language, "configureSettings") },
|
|
109
|
+
{ id: "language", label: t(language, "switchLanguage") },
|
|
110
|
+
{ id: "cancel", label: t(language, "cancel") },
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function listSessionProjects(): string[] {
|
|
115
|
+
if (!fs.existsSync(SESSIONS_DIR)) return [];
|
|
116
|
+
return fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })
|
|
117
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("--") && entry.name.endsWith("--"))
|
|
118
|
+
.map((entry) => entry.name)
|
|
119
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sessionDirToPath(dirName: string): string {
|
|
123
|
+
let value = dirName;
|
|
124
|
+
if (value.startsWith("--")) value = value.slice(2);
|
|
125
|
+
if (value.endsWith("--")) value = value.slice(0, -2);
|
|
126
|
+
return value ? `/${value.replace(/-/g, "/")}` : dirName;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sessionSelectionLabel(config: SyncConfig): string {
|
|
130
|
+
const language = config.language;
|
|
131
|
+
if (config.sessionProjects.length === 0) {
|
|
132
|
+
return t(language, config.sessionProjectMode === "blacklist" ? "projectSelectionAll" : "projectSelectionWhitelist");
|
|
133
|
+
}
|
|
134
|
+
const mode = t(language, config.sessionProjectMode === "blacklist" ? "projectModeBlacklist" : "projectModeWhitelist");
|
|
135
|
+
return `${mode}: ${config.sessionProjects.length}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function showSessionProjectSelect(ctx: ExtensionCommandContext, config: SyncConfig): Promise<void> {
|
|
139
|
+
let cursor = 0;
|
|
140
|
+
while (true) {
|
|
141
|
+
const language = config.language;
|
|
142
|
+
const projects = listSessionProjects();
|
|
143
|
+
const modeLabel = t(language, config.sessionProjectMode === "blacklist" ? "projectModeBlacklist" : "projectModeWhitelist");
|
|
144
|
+
const items: SelectItem<string>[] = [
|
|
145
|
+
{ id: "mode", label: t(language, "switchProjectMode", { mode: modeLabel }) },
|
|
146
|
+
{ id: "all", label: t(language, "selectAllProjects") },
|
|
147
|
+
{ id: "reset", label: t(language, "resetProjects") },
|
|
148
|
+
...projects.map((project) => ({
|
|
149
|
+
id: `project:${project}`,
|
|
150
|
+
label: `[${config.sessionProjects.includes(project) ? "x" : " "}] ${sessionDirToPath(project)}`,
|
|
151
|
+
})),
|
|
152
|
+
{ id: "back", label: t(language, "back") },
|
|
153
|
+
];
|
|
154
|
+
const action = await selectAction(ctx, t(language, "selectSessionProjects", { mode: modeLabel }), items, cursor);
|
|
155
|
+
if (!action || action === "back") return;
|
|
156
|
+
cursor = Math.max(0, items.findIndex((item) => item.id === action));
|
|
157
|
+
if (action === "mode") {
|
|
158
|
+
config.sessionProjectMode = config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist";
|
|
159
|
+
} else if (action === "all") {
|
|
160
|
+
config.sessionProjects = [...projects];
|
|
161
|
+
} else if (action === "reset") {
|
|
162
|
+
config.sessionProjects = [];
|
|
163
|
+
} else if (action.startsWith("project:")) {
|
|
164
|
+
const project = action.slice("project:".length);
|
|
165
|
+
config.sessionProjects = config.sessionProjects.includes(project)
|
|
166
|
+
? config.sessionProjects.filter((item) => item !== project)
|
|
167
|
+
: [...config.sessionProjects, project];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
49
172
|
export async function showSetupWizard(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
50
|
-
const config = loadConfig();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
173
|
+
const config = cloneConfig(loadConfig());
|
|
174
|
+
const language = config.language;
|
|
175
|
+
ctx.ui.notify(t(language, "setupRequired"), "warning");
|
|
176
|
+
const url = await ctx.ui.input(t(language, "promptWebdavUrl"), config.webdavUrl); if (!url) return false;
|
|
177
|
+
const user = await ctx.ui.input(t(language, "promptWebdavUsername"), config.webdavUser); if (!user) return false;
|
|
178
|
+
const pass = await ctx.ui.input(t(language, "promptWebdavPassword"), config.webdavPass); if (!pass) return false;
|
|
179
|
+
config.webdavUrl = url.trim();
|
|
180
|
+
config.webdavUser = user.trim();
|
|
181
|
+
config.webdavPass = pass.trim();
|
|
182
|
+
saveConfig(config, ctx);
|
|
57
183
|
return true;
|
|
58
184
|
}
|
|
59
185
|
|
|
60
186
|
export async function showConfigureSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
61
|
-
const config = loadConfig();
|
|
187
|
+
const config = cloneConfig(loadConfig());
|
|
188
|
+
let cursor = 0;
|
|
62
189
|
while (true) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
190
|
+
const language = config.language;
|
|
191
|
+
const items: SelectItem<string>[] = [
|
|
192
|
+
{ id: "url", label: t(language, "webdavUrl", { value: config.webdavUrl || t(language, "notSet") }) },
|
|
193
|
+
{ id: "user", label: t(language, "webdavUsername", { value: config.webdavUser || t(language, "notSet") }) },
|
|
194
|
+
{ id: "password", label: t(language, "webdavPassword", { value: config.webdavPass ? t(language, "passwordSet") : t(language, "notSet") }) },
|
|
195
|
+
{ id: "pi", label: t(language, "piBackup", { value: onOff(language, config.backupProviders) }) },
|
|
196
|
+
{ id: "skills", label: t(language, "skillsBackup", { value: onOff(language, config.backupAgentSkills) }) },
|
|
197
|
+
{ id: "sessions", label: t(language, "sessionsBackup", { value: onOff(language, config.backupSessions) }) },
|
|
198
|
+
{ id: "exit", label: t(language, "backupOnExit", { value: onOff(language, config.backupOnExit) }) },
|
|
199
|
+
{ id: "exclude", label: t(language, "piExcludePaths", { value: config.piExcludePaths.join(", ") || t(language, "none") }) },
|
|
200
|
+
{ id: "projects", label: t(language, "sessionProjects", { value: sessionSelectionLabel(config) }) },
|
|
201
|
+
{ id: "mode", label: t(language, "sessionProjectMode", { value: t(language, config.sessionProjectMode === "blacklist" ? "projectModeBlacklist" : "projectModeWhitelist") }) },
|
|
202
|
+
{ id: "max", label: t(language, "maxCloudBackups", { value: config.maxBackups === 0 ? t(language, "keepAll") : config.maxBackups }) },
|
|
203
|
+
{ id: "save", label: t(language, "save") },
|
|
204
|
+
{ id: "back", label: t(language, "back") },
|
|
205
|
+
];
|
|
206
|
+
const action = await selectAction(ctx, t(language, "configureTitle"), items, cursor);
|
|
207
|
+
if (!action || action === "back") return;
|
|
208
|
+
cursor = Math.max(0, items.findIndex((item) => item.id === action));
|
|
209
|
+
if (action === "save") {
|
|
210
|
+
saveConfig(config, ctx);
|
|
211
|
+
ctx.ui.notify(t(language, "configSaved"), "info");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (action === "url") {
|
|
215
|
+
const value = await ctx.ui.input(t(language, "promptWebdavUrl"), config.webdavUrl);
|
|
216
|
+
if (value) config.webdavUrl = value.trim();
|
|
217
|
+
} else if (action === "user") {
|
|
218
|
+
const value = await ctx.ui.input(t(language, "promptWebdavUsername"), config.webdavUser);
|
|
219
|
+
if (value) config.webdavUser = value.trim();
|
|
220
|
+
} else if (action === "password") {
|
|
221
|
+
const value = await ctx.ui.input(t(language, "promptWebdavPassword"), config.webdavPass);
|
|
222
|
+
if (value) config.webdavPass = value.trim();
|
|
223
|
+
} else if (action === "pi") {
|
|
224
|
+
config.backupProviders = !config.backupProviders;
|
|
225
|
+
} else if (action === "skills") {
|
|
226
|
+
config.backupAgentSkills = !config.backupAgentSkills;
|
|
227
|
+
} else if (action === "sessions") {
|
|
228
|
+
config.backupSessions = !config.backupSessions;
|
|
229
|
+
} else if (action === "exit") {
|
|
230
|
+
config.backupOnExit = !config.backupOnExit;
|
|
231
|
+
} else if (action === "exclude") {
|
|
232
|
+
const value = await ctx.ui.input(t(language, "promptExcludePaths"), config.piExcludePaths.join(", "));
|
|
233
|
+
if (value !== undefined) config.piExcludePaths = normalizePiExcludePaths(value.split(/[\n,]/));
|
|
234
|
+
} else if (action === "projects") {
|
|
235
|
+
await showSessionProjectSelect(ctx, config);
|
|
236
|
+
} else if (action === "mode") {
|
|
237
|
+
config.sessionProjectMode = config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist";
|
|
238
|
+
} else if (action === "max") {
|
|
239
|
+
const value = await ctx.ui.input(t(language, "promptMaxBackups"), String(config.maxBackups));
|
|
240
|
+
const count = value ? Number.parseInt(value, 10) : Number.NaN;
|
|
241
|
+
if (count >= 0) config.maxBackups = count;
|
|
242
|
+
}
|
|
96
243
|
}
|
|
97
244
|
}
|
|
98
245
|
|
|
99
|
-
|
|
246
|
+
function packageMeta(kind: BackupKind): { filename: string; dir: string; prefix: string } {
|
|
247
|
+
const timestamp = archiveTimestamp();
|
|
248
|
+
return kind === "pi"
|
|
249
|
+
? { filename: `pi_agent_${platformTag()}_${timestamp}.tar.xz`, dir: WEBDAV_PI_BACKUP_DIR, prefix: "pi_agent_" }
|
|
250
|
+
: { filename: `agent_skills_${timestamp}.tar.xz`, dir: WEBDAV_AGENT_SKILLS_DIR, prefix: "agent_skills_" };
|
|
251
|
+
}
|
|
100
252
|
|
|
101
|
-
async function showUploadPackage(ctx: ExtensionCommandContext, kind: BackupKind): Promise<
|
|
253
|
+
async function showUploadPackage(ctx: ExtensionCommandContext, kind: BackupKind, notify = true): Promise<boolean> {
|
|
102
254
|
const config = loadConfig();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
255
|
+
const language = config.language;
|
|
256
|
+
const label = kindLabel(language, kind);
|
|
257
|
+
const enabled = kind === "pi" ? config.backupProviders : config.backupAgentSkills;
|
|
258
|
+
if (!enabled) {
|
|
259
|
+
if (notify) ctx.ui.notify(t(language, "backupDisabled", { kind: label }), "warning");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const meta = packageMeta(kind);
|
|
111
263
|
const archive = path.join(os.tmpdir(), meta.filename);
|
|
112
264
|
try {
|
|
113
|
-
const contents = kind === "
|
|
265
|
+
const contents = kind === "pi"
|
|
266
|
+
? await createPiAgentZip(config, archive)
|
|
267
|
+
: await createAgentSkillsZip(archive);
|
|
114
268
|
await uploadToWebdavDir(archive, meta.dir, meta.filename, config, ctx);
|
|
115
269
|
const deleted = await pruneOldBackupsInDir(config, ctx, meta.dir, meta.prefix);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
270
|
+
const pruned = deleted.length ? t(language, "prunedCount", { count: deleted.length }) : "";
|
|
271
|
+
if (notify) ctx.ui.notify(t(language, "backupUploaded", { filename: meta.filename, contents: contents.join("\n"), pruned }), "info");
|
|
272
|
+
return true;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (notify) ctx.ui.notify(t(language, "backupFailed", { kind: label, error: error instanceof Error ? error.message : String(error) }), "error");
|
|
275
|
+
return false;
|
|
276
|
+
} finally { fs.rmSync(archive, { force: true }); }
|
|
119
277
|
}
|
|
120
278
|
|
|
121
|
-
async function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
279
|
+
export async function uploadSessionProjectArchive(
|
|
280
|
+
ctx: ExtensionContext,
|
|
281
|
+
config: SyncConfig,
|
|
282
|
+
project: string,
|
|
283
|
+
notify = true,
|
|
284
|
+
): Promise<boolean> {
|
|
285
|
+
const language = config.language;
|
|
127
286
|
const filename = `sessions_${platformTag()}_${archiveTimestamp()}.tar.xz`;
|
|
128
|
-
const archive = path.join(os.tmpdir(), filename)
|
|
287
|
+
const archive = path.join(os.tmpdir(), filename);
|
|
288
|
+
const remoteDir = `${WEBDAV_SESSIONS_ARCHIVE_DIR}${project}/`;
|
|
129
289
|
try {
|
|
130
290
|
const contents = await createSessionsArchiveZip(project, archive);
|
|
131
291
|
await uploadToWebdavDir(archive, remoteDir, filename, config, ctx);
|
|
132
292
|
await pruneOldBackupsInDir(config, ctx, remoteDir, "sessions_");
|
|
133
|
-
ctx.ui.notify(
|
|
134
|
-
|
|
135
|
-
|
|
293
|
+
if (notify) ctx.ui.notify(t(language, "sessionArchiveUploaded", { filename, contents: contents.join("\n") }), "info");
|
|
294
|
+
return true;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (notify) ctx.ui.notify(t(language, "sessionArchiveFailed", { error: error instanceof Error ? error.message : String(error) }), "error");
|
|
297
|
+
return false;
|
|
298
|
+
} finally { fs.rmSync(archive, { force: true }); }
|
|
136
299
|
}
|
|
137
300
|
|
|
138
|
-
async function
|
|
301
|
+
async function showUploadSessionsArchive(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
139
302
|
const config = loadConfig();
|
|
140
|
-
const
|
|
303
|
+
const language = config.language;
|
|
304
|
+
if (!config.backupSessions) {
|
|
305
|
+
ctx.ui.notify(t(language, "backupDisabled", { kind: t(language, "sessionsKind") }), "warning");
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
const projects = listSessionProjects().filter((project) => isProjectAllowed(project, config));
|
|
309
|
+
if (!projects.length) {
|
|
310
|
+
ctx.ui.notify(t(language, "noAllowedSessionProjects"), "warning");
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const items: SelectItem<string>[] = [
|
|
314
|
+
...projects.map((project) => ({ id: project, label: sessionDirToPath(project) })),
|
|
315
|
+
{ id: "cancel", label: t(language, "cancel") },
|
|
316
|
+
];
|
|
317
|
+
const project = await selectAction(ctx, t(language, "archiveSessionProject"), items);
|
|
318
|
+
if (!project || project === "cancel") return false;
|
|
319
|
+
return uploadSessionProjectArchive(ctx, config, project);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function showRestorePackage(
|
|
323
|
+
ctx: ExtensionCommandContext,
|
|
324
|
+
kind: BackupKind,
|
|
325
|
+
autoLatest = false,
|
|
326
|
+
reloadAfter = true,
|
|
327
|
+
): Promise<boolean> {
|
|
328
|
+
const config = loadConfig();
|
|
329
|
+
const language = config.language;
|
|
330
|
+
const label = kindLabel(language, kind);
|
|
331
|
+
const meta = packageMeta(kind);
|
|
141
332
|
try {
|
|
142
|
-
const files = (await listWebdavDir(meta.dir, config, ctx))
|
|
143
|
-
|
|
333
|
+
const files = (await listWebdavDir(meta.dir, config, ctx))
|
|
334
|
+
.filter((name) => name.startsWith(meta.prefix) && name.endsWith(".tar.xz"))
|
|
335
|
+
.sort()
|
|
336
|
+
.reverse();
|
|
337
|
+
if (!files.length) {
|
|
338
|
+
ctx.ui.notify(t(language, "noArchivesFound", { kind: label }), "warning");
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
144
341
|
let selected: string | undefined;
|
|
145
|
-
if (autoLatest)
|
|
146
|
-
|
|
147
|
-
|
|
342
|
+
if (autoLatest) {
|
|
343
|
+
selected = files[0];
|
|
344
|
+
} else {
|
|
345
|
+
const items: SelectItem<string>[] = [
|
|
346
|
+
...files.map((file) => ({ id: file, label: file })),
|
|
347
|
+
{ id: "cancel", label: t(language, "cancel") },
|
|
348
|
+
];
|
|
349
|
+
selected = await selectAction(ctx, t(language, "restoreArchive", { kind: label }), items);
|
|
350
|
+
}
|
|
351
|
+
if (!selected || selected === "cancel") return false;
|
|
352
|
+
if (kind === "skills" && !await ctx.ui.confirm(
|
|
353
|
+
t(language, "replaceSharedSkillsTitle"),
|
|
354
|
+
t(language, "replaceSharedSkillsBody"),
|
|
355
|
+
)) return false;
|
|
148
356
|
const local = path.join(os.tmpdir(), path.basename(selected));
|
|
149
357
|
try {
|
|
150
358
|
await downloadFromWebdavDir(selected, meta.dir, local, config, ctx);
|
|
151
|
-
|
|
152
|
-
|
|
359
|
+
if (kind === "pi") {
|
|
360
|
+
const entries = await listArchiveEntries(local);
|
|
361
|
+
validateArchiveEntries(entries);
|
|
362
|
+
const plan = getRestorePlan(entries).join("\n");
|
|
363
|
+
if (!await ctx.ui.confirm(
|
|
364
|
+
t(language, "confirmPiRestoreTitle"),
|
|
365
|
+
t(language, "confirmPiRestoreBody", { count: entries.length, plan }),
|
|
366
|
+
)) return false;
|
|
367
|
+
}
|
|
368
|
+
const restored = kind === "pi" ? await extractPiAgentZip(local) : await extractAgentSkillsZip(local);
|
|
369
|
+
if (kind === "pi") {
|
|
370
|
+
try {
|
|
371
|
+
// ctx.reload() 只重载扩展等资源,不会刷新内存中的 ModelRegistry。
|
|
372
|
+
// 禁用网络请求,仅重读刚恢复的 ~/.pi/agent/models.json,使模型选择器立即可见最新目录。
|
|
373
|
+
const refresh = await ctx.modelRegistry.refresh({ allowNetwork: false });
|
|
374
|
+
if (refresh.errors.size > 0) {
|
|
375
|
+
console.warn(`[pi-sync] Model catalog refresh completed with ${refresh.errors.size} error(s)`);
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
// 文件已恢复;模型刷新失败不应让整个恢复操作误报失败,重启 Pi 后仍会重新读取 models.json。
|
|
379
|
+
console.warn(`[pi-sync] Failed to refresh restored model catalog: ${error instanceof Error ? error.message : String(error)}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
ctx.ui.notify(t(language, "restoreCompleted", { kind: label, contents: restored.join("\n") }), "info");
|
|
383
|
+
if (kind === "pi" && reloadAfter && await ctx.ui.confirm(t(language, "reloadRuntimeTitle"), t(language, "reloadRuntimeBody"))) {
|
|
384
|
+
await ctx.reload();
|
|
385
|
+
}
|
|
153
386
|
return true;
|
|
154
387
|
} finally { fs.rmSync(local, { force: true }); }
|
|
155
|
-
} catch (error) {
|
|
388
|
+
} catch (error) {
|
|
389
|
+
ctx.ui.notify(t(language, "restoreFailed", { kind: label, error: error instanceof Error ? error.message : String(error) }), "error");
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
156
392
|
}
|
|
157
393
|
|
|
158
|
-
async function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
394
|
+
async function restoreSessionProjectArchive(
|
|
395
|
+
ctx: ExtensionCommandContext,
|
|
396
|
+
config: SyncConfig,
|
|
397
|
+
project: string,
|
|
398
|
+
autoLatest = false,
|
|
399
|
+
notify = true,
|
|
400
|
+
): Promise<boolean> {
|
|
401
|
+
const language = config.language;
|
|
402
|
+
const remoteDir = `${WEBDAV_SESSIONS_ARCHIVE_DIR}${project}/`;
|
|
403
|
+
try {
|
|
404
|
+
const files = (await listWebdavDir(remoteDir, config, ctx))
|
|
405
|
+
.filter((name) => name.startsWith("sessions_") && name.endsWith(".tar.xz"))
|
|
406
|
+
.sort()
|
|
407
|
+
.reverse();
|
|
408
|
+
if (!files.length) {
|
|
409
|
+
if (notify) ctx.ui.notify(t(language, "noArchivesFound", { kind: t(language, "sessionsKind") }), "warning");
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
let selected: string | undefined;
|
|
413
|
+
if (autoLatest) {
|
|
414
|
+
selected = files[0];
|
|
415
|
+
} else {
|
|
416
|
+
const archiveItems: SelectItem<string>[] = [
|
|
417
|
+
...files.map((file) => ({ id: file, label: file })),
|
|
418
|
+
{ id: "cancel", label: t(language, "cancel") },
|
|
419
|
+
];
|
|
420
|
+
selected = await selectAction(ctx, t(language, "selectSessionArchive"), archiveItems);
|
|
421
|
+
}
|
|
422
|
+
if (!selected || selected === "cancel") return false;
|
|
423
|
+
const local = path.join(os.tmpdir(), path.basename(selected));
|
|
424
|
+
try {
|
|
425
|
+
await downloadFromWebdavDir(selected, remoteDir, local, config, ctx);
|
|
426
|
+
const restored = await extractSessionsArchiveZip(local);
|
|
427
|
+
if (notify) ctx.ui.notify(t(language, "sessionRestoreCompleted", { contents: restored.join("\n") }), "info");
|
|
428
|
+
return true;
|
|
429
|
+
} finally { fs.rmSync(local, { force: true }); }
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (notify) ctx.ui.notify(t(language, "sessionRestoreFailed", { error: error instanceof Error ? error.message : String(error) }), "error");
|
|
432
|
+
return false;
|
|
167
433
|
}
|
|
168
|
-
ctx.ui.notify(`Upload All complete:\n${results.join("\n")}\n\n💡 Sessions: use 🗂️ Upload Sessions Archive or 🔄 Session Sync.`, "info");
|
|
169
434
|
}
|
|
170
435
|
|
|
171
|
-
async function
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const ok = await showRestorePackage(ctx, kind, true);
|
|
175
|
-
results.push(ok ? `✅ ${kind}` : `⏭️ ${kind} (skipped)`);
|
|
176
|
-
}
|
|
177
|
-
ctx.ui.notify(`Restore All complete:\n${results.join("\n")}`, "info");
|
|
178
|
-
if (results.some((r) => r.startsWith("✅")) && await ctx.ui.confirm("Reload Runtime?", "Reload Pi to apply the restored data?")) await ctx.reload();
|
|
436
|
+
async function listRemoteSessionProjects(ctx: ExtensionCommandContext, config: SyncConfig): Promise<string[]> {
|
|
437
|
+
const base = webdavDirBase(config, WEBDAV_SESSIONS_ARCHIVE_DIR);
|
|
438
|
+
return webdavList(base, webdavAuth(config), ctx, (name) => name.startsWith("--") && name.endsWith("--"));
|
|
179
439
|
}
|
|
180
440
|
|
|
181
|
-
async function showRestoreSessionsArchive(ctx: ExtensionCommandContext): Promise<
|
|
441
|
+
async function showRestoreSessionsArchive(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
182
442
|
const config = loadConfig();
|
|
443
|
+
const language = config.language;
|
|
183
444
|
try {
|
|
184
|
-
const projects = await
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
445
|
+
const projects = (await listRemoteSessionProjects(ctx, config)).filter((project) => isProjectAllowed(project, config));
|
|
446
|
+
if (!projects.length) {
|
|
447
|
+
ctx.ui.notify(t(language, "noRemoteSessionProjects"), "warning");
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
const projectItems: SelectItem<string>[] = [
|
|
451
|
+
...projects.map((project) => ({ id: project, label: sessionDirToPath(project) })),
|
|
452
|
+
{ id: "cancel", label: t(language, "cancel") },
|
|
453
|
+
];
|
|
454
|
+
const project = await selectAction(ctx, t(language, "restoreSessionProject"), projectItems);
|
|
455
|
+
if (!project || project === "cancel") return false;
|
|
456
|
+
return restoreSessionProjectArchive(ctx, config, project);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
ctx.ui.notify(t(language, "sessionRestoreFailed", { error: error instanceof Error ? error.message : String(error) }), "error");
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
193
461
|
}
|
|
194
462
|
|
|
195
|
-
async function
|
|
196
|
-
|
|
463
|
+
export async function executeBackupAll(config: SyncConfig, operations: BackupAllOperations): Promise<BulkResult[]> {
|
|
464
|
+
const results: BulkResult[] = [];
|
|
465
|
+
if (config.backupProviders) {
|
|
466
|
+
results.push({ kind: "pi", success: await operations.uploadPi() });
|
|
467
|
+
}
|
|
468
|
+
if (config.backupAgentSkills) {
|
|
469
|
+
results.push({ kind: "skills", success: await operations.uploadSkills() });
|
|
470
|
+
}
|
|
471
|
+
if (config.backupSessions) {
|
|
472
|
+
const projects = operations.listProjects().filter((project) => isProjectAllowed(project, config));
|
|
473
|
+
for (const project of projects) {
|
|
474
|
+
results.push({ kind: "session", project, success: await operations.uploadSession(project) });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return results;
|
|
197
478
|
}
|
|
198
479
|
|
|
199
|
-
async function
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
480
|
+
export async function executeRestoreAll(config: SyncConfig, operations: RestoreAllOperations): Promise<BulkResult[]> {
|
|
481
|
+
const results: BulkResult[] = [];
|
|
482
|
+
if (config.backupProviders) {
|
|
483
|
+
results.push({ kind: "pi", success: await operations.restorePi() });
|
|
484
|
+
}
|
|
485
|
+
if (config.backupAgentSkills) {
|
|
486
|
+
results.push({ kind: "skills", success: await operations.restoreSkills() });
|
|
487
|
+
}
|
|
488
|
+
if (config.backupSessions) {
|
|
489
|
+
try {
|
|
490
|
+
const projects = (await operations.listProjects()).filter((project) => isProjectAllowed(project, config));
|
|
491
|
+
for (const project of projects) {
|
|
492
|
+
results.push({ kind: "session", project, success: await operations.restoreSession(project) });
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
results.push({ kind: "session", success: false, error: errorMessage(error) });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return results;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function errorMessage(error: unknown): string {
|
|
502
|
+
return error instanceof Error ? error.message : String(error);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function formatBulkResults(language: SyncLanguage, results: BulkResult[]): string {
|
|
506
|
+
return results.map((result) => {
|
|
507
|
+
const label = result.kind === "pi"
|
|
508
|
+
? t(language, "piKind")
|
|
509
|
+
: result.kind === "skills"
|
|
510
|
+
? t(language, "skillsKind")
|
|
511
|
+
: result.project ? sessionDirToPath(result.project) : t(language, "sessionsKind");
|
|
512
|
+
return `${result.success ? "✅" : "❌"} ${label}${result.error ? `: ${result.error}` : ""}`;
|
|
513
|
+
}).join("\n");
|
|
204
514
|
}
|
|
205
515
|
|
|
206
|
-
async function
|
|
516
|
+
async function showUploadAll(ctx: ExtensionCommandContext): Promise<void> {
|
|
207
517
|
const config = loadConfig();
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
518
|
+
const language = config.language;
|
|
519
|
+
const results = await executeBackupAll(config, {
|
|
520
|
+
uploadPi: () => showUploadPackage(ctx, "pi", false),
|
|
521
|
+
uploadSkills: () => showUploadPackage(ctx, "skills", false),
|
|
522
|
+
listProjects: () => listSessionProjects(),
|
|
523
|
+
uploadSession: (project) => uploadSessionProjectArchive(ctx, config, project, false),
|
|
524
|
+
});
|
|
525
|
+
ctx.ui.notify(t(language, "allBackupCompleted", { results: formatBulkResults(language, results) || t(language, "none") }), "info");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function showRestoreAll(ctx: ExtensionCommandContext): Promise<void> {
|
|
529
|
+
const config = loadConfig();
|
|
530
|
+
const language = config.language;
|
|
531
|
+
if (!await ctx.ui.confirm(t(language, "confirmRestoreAllTitle"), t(language, "confirmRestoreAllBody"))) return;
|
|
532
|
+
const results = await executeRestoreAll(config, {
|
|
533
|
+
restorePi: () => showRestorePackage(ctx, "pi", true, false),
|
|
534
|
+
restoreSkills: () => showRestorePackage(ctx, "skills", true, false),
|
|
535
|
+
listProjects: () => listRemoteSessionProjects(ctx, config),
|
|
536
|
+
restoreSession: (project) => restoreSessionProjectArchive(ctx, config, project, true, false),
|
|
537
|
+
});
|
|
538
|
+
ctx.ui.notify(t(language, "allRestoreCompleted", { results: formatBulkResults(language, results) || t(language, "none") }), "info");
|
|
539
|
+
if (results.some((result) => result.success)
|
|
540
|
+
&& await ctx.ui.confirm(t(language, "reloadRuntimeTitle"), t(language, "reloadRuntimeBody"))) {
|
|
541
|
+
await ctx.reload();
|
|
542
|
+
}
|
|
221
543
|
}
|
|
222
544
|
|
|
223
545
|
export async function handleSyncCommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
224
546
|
let config = loadConfig();
|
|
225
|
-
if (!config.webdavUrl || !config.webdavUser || !config.webdavPass) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const action = await enhancedSelect(ctx, "Legacy backup", ["Upload legacy backup", "Restore legacy backup", "❌ Cancel"]);
|
|
247
|
-
if (action?.startsWith("Upload")) return showUploadLegacy(ctx);
|
|
248
|
-
if (action?.startsWith("Restore")) return showRestoreLegacy(ctx);
|
|
547
|
+
if (!config.webdavUrl || !config.webdavUser || !config.webdavPass) {
|
|
548
|
+
if (!await showSetupWizard(ctx)) return;
|
|
549
|
+
}
|
|
550
|
+
while (true) {
|
|
551
|
+
config = loadConfig();
|
|
552
|
+
const language = config.language;
|
|
553
|
+
const items = buildMainMenuItems(language);
|
|
554
|
+
const action = await selectAction(ctx, t(language, "menuTitle"), items);
|
|
555
|
+
if (!action || action === "cancel") return;
|
|
556
|
+
if (action === "upload-all") await showUploadAll(ctx);
|
|
557
|
+
else if (action === "restore-all") await showRestoreAll(ctx);
|
|
558
|
+
else if (action === "upload-pi") await showUploadPackage(ctx, "pi");
|
|
559
|
+
else if (action === "upload-skills") await showUploadPackage(ctx, "skills");
|
|
560
|
+
else if (action === "upload-sessions") await showUploadSessionsArchive(ctx);
|
|
561
|
+
else if (action === "restore-pi") await showRestorePackage(ctx, "pi");
|
|
562
|
+
else if (action === "restore-skills") await showRestorePackage(ctx, "skills");
|
|
563
|
+
else if (action === "restore-sessions") await showRestoreSessionsArchive(ctx);
|
|
564
|
+
else if (action === "configure") await showConfigureSettings(ctx);
|
|
565
|
+
else if (action === "language") {
|
|
566
|
+
saveConfig({ ...config, language: language === "zh" ? "en" : "zh" }, ctx);
|
|
567
|
+
}
|
|
249
568
|
}
|
|
250
569
|
}
|
|
251
570
|
|
|
252
571
|
export function registerSyncCommand(pi: ExtensionAPI): void {
|
|
253
572
|
pi.registerCommand("sync", {
|
|
254
|
-
description: "
|
|
573
|
+
description: "Back up and restore Pi data via WebDAV",
|
|
255
574
|
getArgumentCompletions: () => null,
|
|
256
575
|
handler: async (_args, ctx) => handleSyncCommand(ctx),
|
|
257
576
|
});
|