@wuyaos/pi-sync 1.1.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/LICENSE +21 -0
- package/PROMO.md +95 -0
- package/README.md +205 -0
- package/README.zh-CN.md +205 -0
- package/docs/sync-menu.png +0 -0
- package/extensions/_shared/box-drawing.ts +58 -0
- package/extensions/_shared/enhanced-select.ts +477 -0
- package/extensions/_shared/fetch-utils.ts +46 -0
- package/extensions/_shared/json-io.ts +120 -0
- package/extensions/_shared/spawn.ts +91 -0
- package/extensions/sync/archive.ts +264 -0
- package/extensions/sync/config.ts +93 -0
- package/extensions/sync/index.ts +59 -0
- package/extensions/sync/menus.ts +258 -0
- package/extensions/sync/restore.ts +140 -0
- package/extensions/sync/session-sync.ts +231 -0
- package/extensions/sync/webdav.ts +91 -0
- package/package.json +36 -0
- package/pi-bootstrap.ps1 +113 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { enhancedSelect } from "../_shared/enhanced-select";
|
|
6
|
+
import {
|
|
7
|
+
archiveTimestamp,
|
|
8
|
+
createAgentSkillsZip,
|
|
9
|
+
createConfigZip,
|
|
10
|
+
createLegacyZip,
|
|
11
|
+
createMemoryZip,
|
|
12
|
+
createSessionsArchiveZip,
|
|
13
|
+
listArchiveEntries,
|
|
14
|
+
platformTag,
|
|
15
|
+
validateArchiveEntries,
|
|
16
|
+
yieldToUI,
|
|
17
|
+
} from "./archive";
|
|
18
|
+
import { loadConfig, saveConfig, isProjectAllowed, type SyncConfig } from "./config";
|
|
19
|
+
import {
|
|
20
|
+
extractAgentSkillsZip,
|
|
21
|
+
extractConfigZip,
|
|
22
|
+
extractLegacyZip,
|
|
23
|
+
extractMemoryZip,
|
|
24
|
+
extractSessionsArchiveZip,
|
|
25
|
+
getRestorePlan,
|
|
26
|
+
} from "./restore";
|
|
27
|
+
import {
|
|
28
|
+
describeSessionSelection,
|
|
29
|
+
listSessionProjects,
|
|
30
|
+
sessionDirToPath,
|
|
31
|
+
showSessionProjectSelect,
|
|
32
|
+
showSessionSyncMenu,
|
|
33
|
+
} from "./session-sync";
|
|
34
|
+
import {
|
|
35
|
+
downloadFromWebdavDir,
|
|
36
|
+
listWebdavDir,
|
|
37
|
+
pruneOldBackupsInDir,
|
|
38
|
+
sessionsWebdavBase,
|
|
39
|
+
uploadToWebdavDir,
|
|
40
|
+
WEBDAV_AGENT_SKILLS_DIR,
|
|
41
|
+
WEBDAV_CONFIG_DIR,
|
|
42
|
+
WEBDAV_MEMORY_DIR,
|
|
43
|
+
WEBDAV_SESSIONS_DIR,
|
|
44
|
+
webdavAuth,
|
|
45
|
+
webdavGetFile,
|
|
46
|
+
webdavList,
|
|
47
|
+
} from "./webdav";
|
|
48
|
+
|
|
49
|
+
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);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function showConfigureSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
61
|
+
const config = loadConfig();
|
|
62
|
+
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; }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type BackupKind = "config" | "memory" | "agent-skills";
|
|
100
|
+
|
|
101
|
+
async function showUploadPackage(ctx: ExtensionCommandContext, kind: BackupKind): Promise<void> {
|
|
102
|
+
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_" };
|
|
111
|
+
const archive = path.join(os.tmpdir(), meta.filename);
|
|
112
|
+
try {
|
|
113
|
+
const contents = kind === "config" ? await createConfigZip(config, archive) : kind === "memory" ? await createMemoryZip(archive) : await createAgentSkillsZip(archive);
|
|
114
|
+
await uploadToWebdavDir(archive, meta.dir, meta.filename, config, ctx);
|
|
115
|
+
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 }); }
|
|
119
|
+
}
|
|
120
|
+
|
|
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;
|
|
127
|
+
const filename = `sessions_${platformTag()}_${archiveTimestamp()}.tar.xz`;
|
|
128
|
+
const archive = path.join(os.tmpdir(), filename), remoteDir = `${WEBDAV_SESSIONS_DIR}${project}/archive/`;
|
|
129
|
+
try {
|
|
130
|
+
const contents = await createSessionsArchiveZip(project, archive);
|
|
131
|
+
await uploadToWebdavDir(archive, remoteDir, filename, config, ctx);
|
|
132
|
+
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 }); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function showRestorePackage(ctx: ExtensionCommandContext, kind: BackupKind, autoLatest = false): Promise<boolean> {
|
|
139
|
+
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_" };
|
|
141
|
+
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; }
|
|
144
|
+
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;
|
|
148
|
+
const local = path.join(os.tmpdir(), path.basename(selected));
|
|
149
|
+
try {
|
|
150
|
+
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");
|
|
153
|
+
return true;
|
|
154
|
+
} 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; }
|
|
156
|
+
}
|
|
157
|
+
|
|
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}`);
|
|
167
|
+
}
|
|
168
|
+
ctx.ui.notify(`Upload All complete:\n${results.join("\n")}\n\n💡 Sessions: use 🗂️ Upload Sessions Archive or 🔄 Session Sync.`, "info");
|
|
169
|
+
}
|
|
170
|
+
|
|
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();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function showRestoreSessionsArchive(ctx: ExtensionCommandContext): Promise<void> {
|
|
182
|
+
const config = loadConfig();
|
|
183
|
+
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"); }
|
|
193
|
+
}
|
|
194
|
+
|
|
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();
|
|
197
|
+
}
|
|
198
|
+
|
|
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 }); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function showRestoreLegacy(ctx: ExtensionCommandContext): Promise<void> {
|
|
207
|
+
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"); }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function handleSyncCommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
224
|
+
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);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function registerSyncCommand(pi: ExtensionAPI): void {
|
|
253
|
+
pi.registerCommand("sync", {
|
|
254
|
+
description: "Sync configurations, skills, extensions, and sessions via WebDAV",
|
|
255
|
+
getArgumentCompletions: () => null,
|
|
256
|
+
handler: async (_args, ctx) => handleSyncCommand(ctx),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { timestampForBackup } from "../_shared/json-io";
|
|
5
|
+
import { copyRecursiveSync, listArchiveEntries, runTar, validateArchiveEntries } from "./archive";
|
|
6
|
+
import {
|
|
7
|
+
AGENT_DIR,
|
|
8
|
+
AGENT_ROOT_MARKDOWN_FILES,
|
|
9
|
+
AGENT_SKILLS_DIR,
|
|
10
|
+
MEMORY_MARKDOWN_FILES,
|
|
11
|
+
SESSIONS_DIR,
|
|
12
|
+
ensureDir,
|
|
13
|
+
type SyncConfig,
|
|
14
|
+
} from "./config";
|
|
15
|
+
|
|
16
|
+
async function extractToTemp(archivePath: string, prefix: string): Promise<string> {
|
|
17
|
+
const tempDir = path.join(os.tmpdir(), `${prefix}_${Date.now()}`);
|
|
18
|
+
ensureDir(tempDir);
|
|
19
|
+
validateArchiveEntries(await listArchiveEntries(archivePath));
|
|
20
|
+
await runTar(["-x", "-f", archivePath, "-C", tempDir]);
|
|
21
|
+
return tempDir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function backupAndCopyFile(src: string, dest: string): void {
|
|
25
|
+
ensureDir(path.dirname(dest));
|
|
26
|
+
if (fs.existsSync(dest) && fs.statSync(dest).isFile()) fs.copyFileSync(dest, `${dest}.bak-${timestampForBackup()}`);
|
|
27
|
+
fs.copyFileSync(src, dest);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getRestorePlan(entries: string[], config: SyncConfig): string[] {
|
|
31
|
+
const has = (name: string) => entries.some((entry) => entry === name || entry.startsWith(`${name}/`));
|
|
32
|
+
const plan: string[] = [];
|
|
33
|
+
if (has("config")) plan.push(config.backupProviders ? "Config files will be overwritten after timestamped .bak copies are created." : "Config files are present but skipped by current settings.");
|
|
34
|
+
if (has("skills")) plan.push(config.backupSkills ? "Pi skills will be replaced after a timestamped directory backup." : "Pi skills are present but skipped by current settings.");
|
|
35
|
+
if (has("extensions")) plan.push(config.backupExtensions ? "Extensions will be merged after a timestamped directory backup." : "Extensions are present but skipped by current settings.");
|
|
36
|
+
if (has("sessions")) plan.push(config.backupSessions ? "Sessions will be merged into ~/.pi/agent/sessions/." : "Sessions are present but skipped by current settings.");
|
|
37
|
+
if (has("memory")) plan.push(config.backupMemory ? "Durable memory markdown files will be restored after .bak copies." : "Memory files are present but skipped by current settings.");
|
|
38
|
+
if (has("agent-skills")) plan.push(config.backupAgentSkills ? "~/.agents/skills will be replaced after moving the current directory to a backup." : "Shared skills are present but skipped by current settings.");
|
|
39
|
+
return plan.length ? plan : ["No restorable content was found in this archive."];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function extractConfigZip(archivePath: string, config: SyncConfig): Promise<string[]> {
|
|
43
|
+
const tempDir = await extractToTemp(archivePath, "pi_config_extract");
|
|
44
|
+
const restored: string[] = [];
|
|
45
|
+
try {
|
|
46
|
+
const root = path.join(tempDir, "config", "root");
|
|
47
|
+
if (config.backupProviders && fs.existsSync(root)) {
|
|
48
|
+
const allowedMd = new Set<string>(AGENT_ROOT_MARKDOWN_FILES);
|
|
49
|
+
for (const name of fs.readdirSync(root)) {
|
|
50
|
+
const src = path.join(root, name);
|
|
51
|
+
if (!fs.statSync(src).isFile() || (!name.endsWith(".json") && !allowedMd.has(name))) continue;
|
|
52
|
+
backupAndCopyFile(src, path.join(AGENT_DIR, name));
|
|
53
|
+
restored.push(`Config: ${name}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const sub = path.join(tempDir, "config", "sub");
|
|
57
|
+
if (config.backupProviders && fs.existsSync(sub)) {
|
|
58
|
+
const dest = path.join(AGENT_DIR, "config");
|
|
59
|
+
if (fs.existsSync(dest)) copyRecursiveSync(dest, path.join(AGENT_DIR, `config-backup-${timestampForBackup()}`));
|
|
60
|
+
copyRecursiveSync(sub, dest);
|
|
61
|
+
restored.push("Config directory");
|
|
62
|
+
}
|
|
63
|
+
const extensions = path.join(tempDir, "extensions");
|
|
64
|
+
if (config.backupExtensions && fs.existsSync(extensions)) {
|
|
65
|
+
const dest = path.join(AGENT_DIR, "extensions");
|
|
66
|
+
if (fs.existsSync(dest)) copyRecursiveSync(dest, path.join(AGENT_DIR, `extensions-backup-${timestampForBackup()}`));
|
|
67
|
+
copyRecursiveSync(extensions, dest);
|
|
68
|
+
restored.push("Extensions");
|
|
69
|
+
}
|
|
70
|
+
return restored;
|
|
71
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function extractMemoryZip(archivePath: string): Promise<string[]> {
|
|
75
|
+
const tempDir = await extractToTemp(archivePath, "pi_memory_extract");
|
|
76
|
+
const destDir = path.join(AGENT_DIR, "pi-hermes-memory");
|
|
77
|
+
const restored: string[] = [];
|
|
78
|
+
try {
|
|
79
|
+
for (const name of MEMORY_MARKDOWN_FILES) {
|
|
80
|
+
const src = path.join(tempDir, "memory", name);
|
|
81
|
+
if (!fs.existsSync(src)) continue;
|
|
82
|
+
backupAndCopyFile(src, path.join(destDir, name));
|
|
83
|
+
restored.push(name);
|
|
84
|
+
}
|
|
85
|
+
return restored;
|
|
86
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function extractAgentSkillsZip(archivePath: string): Promise<string[]> {
|
|
90
|
+
const tempDir = await extractToTemp(archivePath, "pi_agent_skills_extract");
|
|
91
|
+
try {
|
|
92
|
+
const source = path.join(tempDir, "agent-skills");
|
|
93
|
+
if (!fs.existsSync(source)) throw new Error("Archive does not contain agent-skills/.");
|
|
94
|
+
const backup = path.join(os.homedir(), ".agents", `skills-backup-${timestampForBackup()}`);
|
|
95
|
+
if (fs.existsSync(AGENT_SKILLS_DIR)) fs.renameSync(AGENT_SKILLS_DIR, backup);
|
|
96
|
+
copyRecursiveSync(source, AGENT_SKILLS_DIR);
|
|
97
|
+
return [`Shared skills; previous directory moved to ${backup}`];
|
|
98
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function extractSessionsArchiveZip(archivePath: string): Promise<string[]> {
|
|
102
|
+
const tempDir = await extractToTemp(archivePath, "pi_sessions_extract");
|
|
103
|
+
try {
|
|
104
|
+
const source = path.join(tempDir, "sessions");
|
|
105
|
+
if (!fs.existsSync(source)) throw new Error("Archive does not contain sessions/.");
|
|
106
|
+
copyRecursiveSync(source, SESSIONS_DIR);
|
|
107
|
+
return ["Session archive merged"];
|
|
108
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function extractLegacyZip(archivePath: string, config: SyncConfig): Promise<string[]> {
|
|
112
|
+
const tempDir = await extractToTemp(archivePath, "pi_sync_extract");
|
|
113
|
+
const restored: string[] = [];
|
|
114
|
+
try {
|
|
115
|
+
const configDir = path.join(tempDir, "config");
|
|
116
|
+
if (config.backupProviders && fs.existsSync(configDir)) {
|
|
117
|
+
for (const name of fs.readdirSync(configDir)) {
|
|
118
|
+
const src = path.join(configDir, name);
|
|
119
|
+
if (fs.statSync(src).isFile()) { backupAndCopyFile(src, path.join(AGENT_DIR, name)); restored.push(`Config: ${name}`); }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const skills = path.join(tempDir, "skills");
|
|
123
|
+
if (config.backupSkills && fs.existsSync(skills)) {
|
|
124
|
+
const dest = path.join(AGENT_DIR, "skills"), backup = path.join(AGENT_DIR, `skills-backup-${timestampForBackup()}`);
|
|
125
|
+
if (fs.existsSync(dest)) fs.renameSync(dest, backup);
|
|
126
|
+
copyRecursiveSync(skills, dest);
|
|
127
|
+
restored.push("Skills");
|
|
128
|
+
}
|
|
129
|
+
const extensions = path.join(tempDir, "extensions");
|
|
130
|
+
if (config.backupExtensions && fs.existsSync(extensions)) {
|
|
131
|
+
const dest = path.join(AGENT_DIR, "extensions");
|
|
132
|
+
if (fs.existsSync(dest)) copyRecursiveSync(dest, path.join(AGENT_DIR, `extensions-backup-${timestampForBackup()}`));
|
|
133
|
+
copyRecursiveSync(extensions, dest);
|
|
134
|
+
restored.push("Extensions");
|
|
135
|
+
}
|
|
136
|
+
const sessions = path.join(tempDir, "sessions");
|
|
137
|
+
if (config.backupSessions && fs.existsSync(sessions)) { copyRecursiveSync(sessions, SESSIONS_DIR); restored.push("Sessions"); }
|
|
138
|
+
return restored;
|
|
139
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
140
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { enhancedSelect } from "../_shared/enhanced-select";
|
|
7
|
+
import { readJsonSafe } from "../_shared/json-io";
|
|
8
|
+
import { extractSessionTs } from "./archive";
|
|
9
|
+
import { SESSIONS_DIR, ensureDir, isProjectAllowed, loadConfig, saveConfig, type SyncConfig } from "./config";
|
|
10
|
+
import { ensureWebdavDirectory, sessionsWebdavBase, WEBDAV_SESSIONS_DIR, webdavAuth, webdavGetFile, webdavList, webdavPutFile } from "./webdav";
|
|
11
|
+
|
|
12
|
+
export interface SessionSyncState {
|
|
13
|
+
currentSessionFile?: string;
|
|
14
|
+
currentProjectDir?: string;
|
|
15
|
+
liveBackupTimer?: ReturnType<typeof setTimeout>;
|
|
16
|
+
turnCounter: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const state: SessionSyncState = { turnCounter: 0 };
|
|
20
|
+
|
|
21
|
+
export function setSessionContext(file?: string, dir?: string): void {
|
|
22
|
+
state.currentSessionFile = file;
|
|
23
|
+
state.currentProjectDir = dir ? path.basename(dir) : undefined;
|
|
24
|
+
state.turnCounter = 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function refreshSessionFile(file?: string): void {
|
|
28
|
+
state.currentSessionFile = file ?? state.currentSessionFile;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getCurrentSessionFile(): string | undefined {
|
|
32
|
+
return state.currentSessionFile;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function incrementTurnAndShouldSync(interval: number): boolean {
|
|
36
|
+
if (interval <= 0) return false;
|
|
37
|
+
state.turnCounter++;
|
|
38
|
+
return state.turnCounter % interval === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function currentProjectIsAllowed(config: SyncConfig): boolean {
|
|
42
|
+
return isProjectAllowed(state.currentProjectDir, config);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listSessionProjects(): string[] {
|
|
46
|
+
if (!fs.existsSync(SESSIONS_DIR)) return [];
|
|
47
|
+
return fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })
|
|
48
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("--") && entry.name.endsWith("--"))
|
|
49
|
+
.map((entry) => entry.name)
|
|
50
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sessionDirToPath(dirName: string): string {
|
|
54
|
+
let value = dirName;
|
|
55
|
+
if (value.startsWith("--")) value = value.slice(2);
|
|
56
|
+
if (value.endsWith("--")) value = value.slice(0, -2);
|
|
57
|
+
return value ? `/${value.replace(/-/g, "/")}` : dirName;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function projectDirFromCwd(cwd: string): string {
|
|
61
|
+
return `--${cwd.replace(/\//g, "-")}--`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function describeSessionSelection(config: SyncConfig): string {
|
|
65
|
+
const count = config.sessionProjects.length;
|
|
66
|
+
if (count === 0) return config.sessionProjectMode === "whitelist" ? `whitelist: 0 projects (none)` : `blacklist: ALL projects (empty = all)`;
|
|
67
|
+
return `${config.sessionProjectMode}: ${count} project${count === 1 ? "" : "s"}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function updateLatestMarker(ctx: ExtensionContext, config: SyncConfig, projectDir: string, sessionFile: string): Promise<void> {
|
|
71
|
+
const filename = path.basename(sessionFile), sessionTs = extractSessionTs(filename);
|
|
72
|
+
if (!sessionTs) return;
|
|
73
|
+
const markerUrl = sessionsWebdavBase(config) + encodeURIComponent(projectDir) + "/_latest.json";
|
|
74
|
+
const temp = path.join(os.tmpdir(), `pi_latest_${process.pid}_${Date.now()}.json`);
|
|
75
|
+
let remoteTs: string | undefined;
|
|
76
|
+
try {
|
|
77
|
+
try { await webdavGetFile(markerUrl, temp, webdavAuth(config), ctx); remoteTs = readJsonSafe<{ sessionTs?: string }>(temp, {}).sessionTs; }
|
|
78
|
+
catch (error) { if (!(error instanceof Error) || !/HTTP 404/.test(error.message)) throw error; }
|
|
79
|
+
if (remoteTs && remoteTs >= sessionTs) return;
|
|
80
|
+
fs.writeFileSync(temp, JSON.stringify({ file: filename, sessionTs, machine: os.hostname(), uploadedAt: new Date().toISOString() }, null, 2));
|
|
81
|
+
await webdavPutFile(temp, markerUrl, webdavAuth(config), ctx);
|
|
82
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function uploadCurrentSession(ctx: ExtensionContext, silent = false): Promise<boolean> {
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
if (!state.currentSessionFile || !state.currentProjectDir) {
|
|
88
|
+
if (!silent) ctx.ui.notify("No active session file to upload.", "warning");
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!isProjectAllowed(state.currentProjectDir, config)) return false;
|
|
92
|
+
if (!fs.existsSync(state.currentSessionFile)) {
|
|
93
|
+
if (!silent) ctx.ui.notify(`Session file missing: ${state.currentSessionFile}`, "warning");
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await ensureWebdavDirectory(`${WEBDAV_SESSIONS_DIR}${state.currentProjectDir}/`, config, ctx);
|
|
98
|
+
const remote = sessionsWebdavBase(config) + encodeURIComponent(state.currentProjectDir) + "/" + encodeURIComponent(path.basename(state.currentSessionFile));
|
|
99
|
+
await webdavPutFile(state.currentSessionFile, remote, webdavAuth(config), ctx);
|
|
100
|
+
await updateLatestMarker(ctx, config, state.currentProjectDir, state.currentSessionFile);
|
|
101
|
+
if (!silent) ctx.ui.notify(`☁️ Session uploaded: ${path.basename(state.currentSessionFile)}`, "info");
|
|
102
|
+
return true;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!silent) ctx.ui.notify(`❌ Session upload failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function scheduleLiveBackup(ctx: ExtensionContext): void {
|
|
110
|
+
const config = loadConfig();
|
|
111
|
+
if (!config.liveSessionBackup || !currentProjectIsAllowed(config)) return;
|
|
112
|
+
clearLiveBackupTimer();
|
|
113
|
+
state.liveBackupTimer = setTimeout(() => {
|
|
114
|
+
state.liveBackupTimer = undefined;
|
|
115
|
+
uploadCurrentSession(ctx, true).catch(() => { /* silent */ });
|
|
116
|
+
}, config.liveBackupDebounceMs);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function clearLiveBackupTimer(): void {
|
|
120
|
+
if (state.liveBackupTimer) clearTimeout(state.liveBackupTimer);
|
|
121
|
+
state.liveBackupTimer = undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function listRemoteProjects(config: SyncConfig, ctx: ExtensionContext): Promise<string[]> {
|
|
125
|
+
try { return await webdavList(sessionsWebdavBase(config), webdavAuth(config), ctx, (name) => name.startsWith("--") && name.endsWith("--")); }
|
|
126
|
+
catch (error) { if (error instanceof Error && /HTTP 404/.test(error.message)) return []; throw error; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function showRestoreLatest(ctx: ExtensionCommandContext): Promise<void> {
|
|
130
|
+
const config = loadConfig();
|
|
131
|
+
try {
|
|
132
|
+
const projects = await listRemoteProjects(config, ctx);
|
|
133
|
+
if (!projects.length) { ctx.ui.notify("No remote session projects found.", "warning"); return; }
|
|
134
|
+
const project = await enhancedSelect(ctx, "Restore latest: select project", [...projects, "❌ Cancel"], { fuzzy: true });
|
|
135
|
+
if (!project || project.includes("Cancel")) return;
|
|
136
|
+
const markerPath = path.join(os.tmpdir(), `pi_latest_${Date.now()}.json`);
|
|
137
|
+
try {
|
|
138
|
+
const base = sessionsWebdavBase(config) + encodeURIComponent(project) + "/";
|
|
139
|
+
await webdavGetFile(base + "_latest.json", markerPath, webdavAuth(config), ctx);
|
|
140
|
+
const marker = readJsonSafe<{ file?: string }>(markerPath, {});
|
|
141
|
+
if (!marker.file || !marker.file.endsWith(".jsonl") || path.basename(marker.file) !== marker.file) throw new Error("Remote _latest.json has no safe session filename.");
|
|
142
|
+
const localDir = path.join(SESSIONS_DIR, project); ensureDir(localDir);
|
|
143
|
+
const localPath = path.join(localDir, marker.file);
|
|
144
|
+
await webdavGetFile(base + encodeURIComponent(marker.file), localPath, webdavAuth(config), ctx);
|
|
145
|
+
ctx.ui.notify(`🎉 Restored latest session into ${localPath}\nUse /resume to continue.`, "info");
|
|
146
|
+
} finally { fs.rmSync(markerPath, { force: true }); }
|
|
147
|
+
} catch (error) { ctx.ui.notify(`❌ Restore latest failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function showRestoreSessions(ctx: ExtensionCommandContext): Promise<void> {
|
|
151
|
+
const config = loadConfig();
|
|
152
|
+
try {
|
|
153
|
+
const projects = await listRemoteProjects(config, ctx);
|
|
154
|
+
if (!projects.length) { ctx.ui.notify("No remote session projects found.", "warning"); return; }
|
|
155
|
+
const project = await enhancedSelect(ctx, "Select remote project to restore", [...projects, "❌ Cancel"], { fuzzy: true });
|
|
156
|
+
if (!project || project.includes("Cancel")) return;
|
|
157
|
+
const base = sessionsWebdavBase(config) + encodeURIComponent(project) + "/";
|
|
158
|
+
const files = await webdavList(base, webdavAuth(config), ctx, (name) => name.endsWith(".jsonl"));
|
|
159
|
+
if (!files.length) { ctx.ui.notify(`No .jsonl files in ${project}.`, "warning"); return; }
|
|
160
|
+
const choice = await enhancedSelect(ctx, `Restore from ${project}`, [...files, "a Restore ALL", "❌ Cancel"], { fuzzy: true });
|
|
161
|
+
if (!choice || choice.includes("Cancel")) return;
|
|
162
|
+
const targets = choice === "a Restore ALL" ? files : [choice];
|
|
163
|
+
const localDir = path.join(SESSIONS_DIR, project); ensureDir(localDir);
|
|
164
|
+
for (const file of targets) await webdavGetFile(base + encodeURIComponent(file), path.join(localDir, file), webdavAuth(config), ctx);
|
|
165
|
+
ctx.ui.notify(`🎉 Restored ${targets.length} session(s) into ${localDir}`, "info");
|
|
166
|
+
} catch (error) { ctx.ui.notify(`❌ Restore failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function showForkSession(ctx: ExtensionCommandContext): Promise<void> {
|
|
170
|
+
const config = loadConfig();
|
|
171
|
+
try {
|
|
172
|
+
const projects = await listRemoteProjects(config, ctx);
|
|
173
|
+
if (!projects.length) { ctx.ui.notify("No remote session projects found.", "warning"); return; }
|
|
174
|
+
const project = await enhancedSelect(ctx, "Fork: select source project", [...projects, "❌ Cancel"], { fuzzy: true });
|
|
175
|
+
if (!project || project.includes("Cancel")) return;
|
|
176
|
+
const base = sessionsWebdavBase(config) + encodeURIComponent(project) + "/";
|
|
177
|
+
const files = await webdavList(base, webdavAuth(config), ctx, (name) => name.endsWith(".jsonl"));
|
|
178
|
+
const file = await enhancedSelect(ctx, "Fork: select session", [...files, "❌ Cancel"], { fuzzy: true });
|
|
179
|
+
if (!file || file.includes("Cancel")) return;
|
|
180
|
+
const temp = path.join(os.tmpdir(), `pi_fork_${Date.now()}.jsonl`);
|
|
181
|
+
try {
|
|
182
|
+
await webdavGetFile(base + encodeURIComponent(file), temp, webdavAuth(config), ctx);
|
|
183
|
+
const manager = SessionManager.forkFrom(temp, process.cwd());
|
|
184
|
+
ctx.ui.notify(`🎉 Forked into: ${manager.getSessionFile() ?? "(unknown)"}`, "info");
|
|
185
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
186
|
+
} catch (error) { ctx.ui.notify(`❌ Fork failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function showSessionProjectSelect(ctx: ExtensionCommandContext, config: SyncConfig): Promise<void> {
|
|
190
|
+
while (true) {
|
|
191
|
+
const projects = listSessionProjects();
|
|
192
|
+
if (!projects.length) { ctx.ui.notify("No local session projects found.", "warning"); return; }
|
|
193
|
+
const selected = new Set(config.sessionProjects);
|
|
194
|
+
const items = projects.map((dir) => `${selected.has(dir) ? "[x]" : "[ ]"} ${sessionDirToPath(dir)}`);
|
|
195
|
+
items.push("───────────────", `m Switch to ${config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist"} mode`, "a Select All", "r Reset list", "x Back");
|
|
196
|
+
const label = config.sessionProjectMode === "whitelist" ? "白名单模式" : "黑名单模式";
|
|
197
|
+
const choice = await enhancedSelect(ctx, `Select Session Projects [${label}]`, items, { fuzzy: true });
|
|
198
|
+
if (!choice || choice === "x Back") { saveConfig(config, ctx); return; }
|
|
199
|
+
if (choice.startsWith("m Switch")) { config.sessionProjectMode = config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist"; saveConfig(config, ctx); continue; }
|
|
200
|
+
if (choice === "a Select All") { config.sessionProjects = [...projects]; saveConfig(config, ctx); continue; }
|
|
201
|
+
if (choice.startsWith("r Reset")) { config.sessionProjects = []; saveConfig(config, ctx); continue; }
|
|
202
|
+
const match = choice.match(/^\[[ x]\]\s+(.*)$/); if (!match) continue;
|
|
203
|
+
const dir = projects.find((candidate) => sessionDirToPath(candidate) === match[1]); if (!dir) continue;
|
|
204
|
+
config.sessionProjects = selected.has(dir) ? config.sessionProjects.filter((item) => item !== dir) : [...config.sessionProjects, dir];
|
|
205
|
+
saveConfig(config, ctx);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function showSessionSyncMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
210
|
+
while (true) {
|
|
211
|
+
const config = loadConfig();
|
|
212
|
+
const current = state.currentSessionFile ? path.basename(state.currentSessionFile) : "(none)";
|
|
213
|
+
const choice = await enhancedSelect(ctx, "Session Sync", [
|
|
214
|
+
`⚡ Live Backup: ${config.liveSessionBackup ? "ON" : "OFF"}`,
|
|
215
|
+
` ↳ Debounce: ${config.liveBackupDebounceMs}ms`,
|
|
216
|
+
`🔄 Sync Interval: ${config.syncIntervalTurns === 0 ? "OFF" : `every ${config.syncIntervalTurns} turns`}`,
|
|
217
|
+
`📤 Sync On Exit: ${config.syncSessionOnExit ? "ON" : "OFF"}`,
|
|
218
|
+
`☁️ Upload Current Session Now (cur: ${current})`,
|
|
219
|
+
"📥 Restore Latest Session", "📥 Restore Sessions", "🌿 Fork Remote Session", "x Back",
|
|
220
|
+
]);
|
|
221
|
+
if (!choice || choice === "x Back") return;
|
|
222
|
+
if (choice.startsWith("⚡")) { config.liveSessionBackup = !config.liveSessionBackup; saveConfig(config, ctx); continue; }
|
|
223
|
+
if (choice.startsWith(" ↳")) { const value = await ctx.ui.input("Debounce ms:", String(config.liveBackupDebounceMs)); const n = value ? parseInt(value, 10) : NaN; if (n > 0) { config.liveBackupDebounceMs = n; saveConfig(config, ctx); } continue; }
|
|
224
|
+
if (choice.startsWith("🔄")) { const value = await ctx.ui.input("Upload every N turns (0 = off):", String(config.syncIntervalTurns)); const n = value ? parseInt(value, 10) : NaN; if (n >= 0) { config.syncIntervalTurns = n; saveConfig(config, ctx); } continue; }
|
|
225
|
+
if (choice.startsWith("📤")) { config.syncSessionOnExit = !config.syncSessionOnExit; saveConfig(config, ctx); continue; }
|
|
226
|
+
if (choice.startsWith("☁️")) { await uploadCurrentSession(ctx); continue; }
|
|
227
|
+
if (choice.startsWith("📥 Restore Latest")) { await showRestoreLatest(ctx); continue; }
|
|
228
|
+
if (choice.startsWith("📥 Restore Sessions")) { await showRestoreSessions(ctx); continue; }
|
|
229
|
+
if (choice.startsWith("🌿")) { await showForkSession(ctx); continue; }
|
|
230
|
+
}
|
|
231
|
+
}
|