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