@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.
@@ -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
- createConfigZip,
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 { loadConfig, saveConfig, isProjectAllowed, type SyncConfig } from "./config";
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
- extractConfigZip,
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
- webdavGetFile,
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
- ctx.ui.notify("WebDAV is not configured. Please set it up now.", "warning");
52
- const url = await ctx.ui.input("WebDAV URL:", config.webdavUrl); if (!url) return false;
53
- const user = await ctx.ui.input("WebDAV username:", config.webdavUser); if (!user) return false;
54
- const pass = await ctx.ui.input("WebDAV token (or $ENV_VAR):", config.webdavPass); if (!pass) return false;
55
- const merged = { ...config, webdavUrl: url.trim(), webdavUser: user.trim(), webdavPass: pass.trim() };
56
- saveConfig(merged, ctx);
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 choice = await enhancedSelect(ctx, "Configure Sync Settings", [
64
- `WebDAV URL: ${config.webdavUrl || "(not set)"}`,
65
- `WebDAV Username: ${config.webdavUser || "(not set)"}`,
66
- `WebDAV Password/Token: ${config.webdavPass ? "(set)" : "(not set)"}`,
67
- `Backup Providers & Config: ${config.backupProviders ? "ON" : "OFF"}`,
68
- `Backup Skills: ${config.backupSkills ? "ON" : "OFF"}`,
69
- `Backup Extensions: ${config.backupExtensions ? "ON" : "OFF"}`,
70
- `Backup Memory Markdown: ${config.backupMemory ? "ON" : "OFF"}`,
71
- `Backup Shared Agent Skills: ${config.backupAgentSkills ? "ON" : "OFF"}`,
72
- `Backup Sessions: ${config.backupSessions ? "ON" : "OFF"}`,
73
- ` ↳ Session Projects: ${describeSessionSelection(config)}`,
74
- `Session Project Mode: ${config.sessionProjectMode}`,
75
- `Sync Every N Turns: ${config.syncIntervalTurns || "OFF"}`,
76
- `Sync Session On Exit: ${config.syncSessionOnExit ? "ON" : "OFF"}`,
77
- `Max Cloud Backups: ${config.maxBackups === 0 ? "keep all" : config.maxBackups}`,
78
- "s Save", "x Back",
79
- ]);
80
- if (!choice || choice === "x Back") return;
81
- if (choice === "s Save") { saveConfig(config, ctx); ctx.ui.notify("Sync configuration updated.", "info"); return; }
82
- if (choice.startsWith("WebDAV URL:")) { const value = await ctx.ui.input("WebDAV URL:", config.webdavUrl); if (value) config.webdavUrl = value.trim(); continue; }
83
- if (choice.startsWith("WebDAV Username:")) { const value = await ctx.ui.input("WebDAV username:", config.webdavUser); if (value) config.webdavUser = value.trim(); continue; }
84
- if (choice.startsWith("WebDAV Password")) { const value = await ctx.ui.input("WebDAV token (or $ENV_VAR):", config.webdavPass); if (value) config.webdavPass = value.trim(); continue; }
85
- if (choice.startsWith("Backup Providers")) { config.backupProviders = !config.backupProviders; continue; }
86
- if (choice.startsWith("Backup Skills:")) { config.backupSkills = !config.backupSkills; continue; }
87
- if (choice.startsWith("Backup Extensions")) { config.backupExtensions = !config.backupExtensions; continue; }
88
- if (choice.startsWith("Backup Memory")) { config.backupMemory = !config.backupMemory; continue; }
89
- if (choice.startsWith("Backup Shared")) { config.backupAgentSkills = !config.backupAgentSkills; continue; }
90
- if (choice.startsWith("Backup Sessions")) { config.backupSessions = !config.backupSessions; continue; }
91
- if (choice.startsWith(" ↳")) { await showSessionProjectSelect(ctx, config); continue; }
92
- if (choice.startsWith("Session Project Mode")) { config.sessionProjectMode = config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist"; continue; }
93
- if (choice.startsWith("Sync Every")) { const value = await ctx.ui.input("Every N turns (0 = off):", String(config.syncIntervalTurns)); const n = value ? parseInt(value, 10) : NaN; if (n >= 0) config.syncIntervalTurns = n; continue; }
94
- if (choice.startsWith("Sync Session On Exit")) { config.syncSessionOnExit = !config.syncSessionOnExit; continue; }
95
- if (choice.startsWith("Max Cloud")) { const value = await ctx.ui.input("Maximum backups (0 = all):", String(config.maxBackups)); const n = value ? parseInt(value, 10) : NaN; if (n >= 0) config.maxBackups = n; continue; }
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
- type BackupKind = "config" | "memory" | "agent-skills";
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<void> {
248
+ async function showUploadPackage(ctx: ExtensionCommandContext, kind: BackupKind, notify = true): Promise<boolean> {
102
249
  const config = loadConfig();
103
- if (kind === "memory" && !config.backupMemory) { ctx.ui.notify("Memory backup is disabled.", "warning"); return; }
104
- if (kind === "agent-skills" && !config.backupAgentSkills) { ctx.ui.notify("Shared skills backup is disabled.", "warning"); return; }
105
- const timestamp = archiveTimestamp();
106
- const meta = kind === "config"
107
- ? { filename: `pi_config_${platformTag()}_${timestamp}.tar.xz`, dir: WEBDAV_CONFIG_DIR, prefix: "pi_config_" }
108
- : kind === "memory"
109
- ? { filename: `memory_${timestamp}.tar.xz`, dir: WEBDAV_MEMORY_DIR, prefix: "memory_" }
110
- : { filename: `agent_skills_${timestamp}.tar.xz`, dir: WEBDAV_AGENT_SKILLS_DIR, prefix: "agent_skills_" };
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 === "config" ? await createConfigZip(config, archive) : kind === "memory" ? await createMemoryZip(archive) : await createAgentSkillsZip(archive);
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
- ctx.ui.notify(`🎉 Uploaded ${meta.filename}\n${contents.join("\n")}${deleted.length ? `\nPruned: ${deleted.length}` : ""}`, "info");
117
- } catch (error) { ctx.ui.notify(`❌ ${kind} backup failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
118
- finally { fs.rmSync(archive, { force: true }); }
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 showUploadSessionsArchive(ctx: ExtensionCommandContext): Promise<void> {
122
- const config = loadConfig();
123
- const projects = listSessionProjects().filter((project) => isProjectAllowed(project, config));
124
- if (!projects.length) { ctx.ui.notify("No allowed local session projects found.", "warning"); return; }
125
- const project = await enhancedSelect(ctx, "Archive session project", [...projects, "❌ Cancel"], { fuzzy: true });
126
- if (!project || project.includes("Cancel")) return;
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), remoteDir = `${WEBDAV_SESSIONS_DIR}${project}/archive/`;
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(`🎉 Uploaded ${filename}\n${contents.join("\n")}`, "info");
134
- } catch (error) { ctx.ui.notify(`❌ Session archive failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
135
- finally { fs.rmSync(archive, { force: true }); }
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 showRestorePackage(ctx: ExtensionCommandContext, kind: BackupKind, autoLatest = false): Promise<boolean> {
296
+ async function showUploadSessionsArchive(ctx: ExtensionCommandContext): Promise<boolean> {
139
297
  const config = loadConfig();
140
- const meta = kind === "config" ? { dir: WEBDAV_CONFIG_DIR, prefix: "pi_config_" } : kind === "memory" ? { dir: WEBDAV_MEMORY_DIR, prefix: "memory_" } : { dir: WEBDAV_AGENT_SKILLS_DIR, prefix: "agent_skills_" };
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)).filter((name) => name.startsWith(meta.prefix) && name.endsWith(".tar.xz")).sort().reverse();
143
- if (!files.length) { ctx.ui.notify(`No ${kind} archives found.`, "warning"); return false; }
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) selected = files[0];
146
- else { selected = await enhancedSelect(ctx, `Restore ${kind} archive`, [...files, "❌ Cancel"], { fuzzy: true }); if (!selected || selected.includes("Cancel")) return false; }
147
- if (kind === "agent-skills" && !await ctx.ui.confirm("Replace shared skills?", "The current ~/.agents/skills will be moved to a timestamped backup.")) return false;
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
- const restored = kind === "config" ? await extractConfigZip(local, config) : kind === "memory" ? await extractMemoryZip(local) : await extractAgentSkillsZip(local);
152
- ctx.ui.notify(`🎉 Restored ${kind}:\n${restored.join("\n")}`, "info");
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) { ctx.ui.notify(`❌ ${kind} restore failed: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
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 showUploadAll(ctx: ExtensionCommandContext): Promise<void> {
159
- const results: string[] = [];
160
- for (const kind of ["config", "memory", "agent-skills"] as BackupKind[]) {
161
- const config = loadConfig();
162
- if (kind === "config" && !config.backupProviders) { results.push(`⏭️ config (disabled)`); continue; }
163
- if (kind === "memory" && !config.backupMemory) { results.push(`⏭️ memory (disabled)`); continue; }
164
- if (kind === "agent-skills" && !config.backupAgentSkills) { results.push(`⏭️ agent-skills (disabled)`); continue; }
165
- await showUploadPackage(ctx, kind);
166
- results.push(`✅ ${kind}`);
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 showRestoreAll(ctx: ExtensionCommandContext): Promise<void> {
172
- const results: string[] = [];
173
- for (const kind of ["config", "memory", "agent-skills"] as BackupKind[]) {
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<void> {
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 webdavList(sessionsWebdavBase(config), webdavAuth(config), ctx, (name) => name.startsWith("--") && name.endsWith("--"));
185
- const project = await enhancedSelect(ctx, "Restore session archive: project", [...projects, "❌ Cancel"], { fuzzy: true }); if (!project || project.includes("Cancel")) return;
186
- const remoteDir = `${WEBDAV_SESSIONS_DIR}${project}/archive/`;
187
- const files = (await listWebdavDir(remoteDir, config, ctx)).filter((name) => name.startsWith("sessions_") && name.endsWith(".tar.xz")).sort().reverse();
188
- const selected = await enhancedSelect(ctx, "Select session archive", [...files, "❌ Cancel"], { fuzzy: true }); if (!selected || selected.includes("Cancel")) return;
189
- const local = path.join(os.tmpdir(), path.basename(selected));
190
- try { await downloadFromWebdavDir(selected, remoteDir, local, config, ctx); ctx.ui.notify(`🎉 ${(await extractSessionsArchiveZip(local)).join("\n")}`, "info"); }
191
- finally { fs.rmSync(local, { force: true }); }
192
- } catch (error) { ctx.ui.notify(`❌ Session restore failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
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 listLegacyBackups(config: SyncConfig, ctx: ExtensionCommandContext): Promise<string[]> {
196
- return (await listWebdavDir("", config, ctx)).filter((name) => name.startsWith("pi_sync_backup_") && (name.endsWith(".tar.xz") || name.endsWith(".zip"))).sort().reverse();
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 showUploadLegacy(ctx: ExtensionCommandContext): Promise<void> {
200
- const config = loadConfig(), filename = `pi_sync_backup_${archiveTimestamp()}_${platformTag()}.tar.xz`, local = path.join(os.tmpdir(), filename);
201
- try { const contents = await createLegacyZip(config, local); await uploadToWebdavDir(local, "", filename, config, ctx); await pruneOldBackupsInDir(config, ctx, "", "pi_sync_backup_"); ctx.ui.notify(`🎉 Legacy backup uploaded\n${contents.join("\n")}`, "info"); }
202
- catch (error) { ctx.ui.notify(`❌ Legacy backup failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
203
- finally { fs.rmSync(local, { force: true }); }
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 showRestoreLegacy(ctx: ExtensionCommandContext): Promise<void> {
498
+ async function showUploadAll(ctx: ExtensionCommandContext): Promise<void> {
207
499
  const config = loadConfig();
208
- try {
209
- const files = await listLegacyBackups(config, ctx); if (!files.length) { ctx.ui.notify("No legacy backups found.", "warning"); return; }
210
- const selected = await enhancedSelect(ctx, "Select legacy backup", [...files, "❌ Cancel"], { fuzzy: true }); if (!selected || selected.includes("Cancel")) return;
211
- const local = path.join(os.tmpdir(), path.basename(selected));
212
- try {
213
- await downloadFromWebdavDir(selected, "", local, config, ctx);
214
- const entries = await listArchiveEntries(local); validateArchiveEntries(entries);
215
- if (!await ctx.ui.confirm("Confirm restore?", [`Entries: ${entries.length}`, ...getRestorePlan(entries, config)].join("\n"))) return;
216
- await yieldToUI();
217
- const restored = await extractLegacyZip(local, config); ctx.ui.notify(`🎉 Restored:\n${restored.join("\n")}`, "info");
218
- if (await ctx.ui.confirm("Reload Runtime?", "Reload Pi now?")) await ctx.reload();
219
- } finally { fs.rmSync(local, { force: true }); }
220
- } catch (error) { ctx.ui.notify(`❌ Legacy restore failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
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) { if (!await showSetupWizard(ctx)) return; config = loadConfig(); }
226
- const choice = await enhancedSelect(ctx, "Pi WebDAV Synchronization", [
227
- "⬆️ Upload All (config + memory + skills)", "⬇️ Restore All (latest)",
228
- "☁️ Upload Config Backup", "🧠 Upload Memory Backup", "📦 Upload Skills Snapshot", "🗂️ Upload Sessions Archive",
229
- "📥 Restore Config Backup", "📥 Restore Memory Backup", "📥 Restore Skills Snapshot", "📥 Restore Sessions Archive",
230
- "🔄 Session Sync", "🧰 Legacy Monolithic Backup/Restore", "⚙️ Configure Sync Settings", "❌ Cancel",
231
- ]);
232
- if (!choice || choice.includes("Cancel")) return;
233
- if (choice.startsWith("⬆️ Upload All")) return showUploadAll(ctx);
234
- if (choice.startsWith("⬇️ Restore All")) return showRestoreAll(ctx);
235
- if (choice.includes("Configure")) return showConfigureSettings(ctx);
236
- if (choice === "☁️ Upload Config Backup") return showUploadPackage(ctx, "config");
237
- if (choice === "🧠 Upload Memory Backup") return showUploadPackage(ctx, "memory");
238
- if (choice === "📦 Upload Skills Snapshot") return showUploadPackage(ctx, "agent-skills");
239
- if (choice === "🗂️ Upload Sessions Archive") return showUploadSessionsArchive(ctx);
240
- if (choice === "📥 Restore Config Backup") return showRestorePackage(ctx, "config");
241
- if (choice === "📥 Restore Memory Backup") return showRestorePackage(ctx, "memory");
242
- if (choice === "📥 Restore Skills Snapshot") return showRestorePackage(ctx, "agent-skills");
243
- if (choice === "📥 Restore Sessions Archive") return showRestoreSessionsArchive(ctx);
244
- if (choice.startsWith("🔄")) return showSessionSyncMenu(ctx);
245
- if (choice.startsWith("🧰")) {
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: "Sync configurations, skills, extensions, and sessions via WebDAV",
555
+ description: "Back up and restore Pi data via WebDAV",
255
556
  getArgumentCompletions: () => null,
256
557
  handler: async (_args, ctx) => handleSyncCommand(ctx),
257
558
  });