@wuyaos/pi-sync 1.2.0 → 1.2.2

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.
@@ -85,6 +85,12 @@ export interface EnhancedSelectOptions {
85
85
  * key takes precedence over filter input, mirroring shortcut-key behavior.
86
86
  */
87
87
  actionKeys?: Array<{ key: string; label: string }>;
88
+ /**
89
+ * Initial highlighted index (0-based). Defaults to 0. Clamped to the
90
+ * valid item range. Use to preserve cursor position across re-renders in
91
+ * looping menus (e.g. a config toggle that re-enters the select).
92
+ */
93
+ initialIndex?: number;
88
94
  }
89
95
 
90
96
  /** Sentinel prefix marking an action-key result from enhancedSelect. */
@@ -143,6 +149,13 @@ class EnhancedSelectComponent {
143
149
  this.shortcutMap.set(key, i);
144
150
  }
145
151
  }
152
+
153
+ // Preserve caller-provided cursor position; clamp to valid range so a
154
+ // stale index from a previous (longer) item list cannot overflow.
155
+ const initial = this.options.initialIndex ?? 0;
156
+ this.selectedIdx = this.items.length > 0
157
+ ? Math.max(0, Math.min(initial, this.items.length - 1))
158
+ : 0;
146
159
  }
147
160
 
148
161
  // ── Fuzzy filter state ──────────────────────────────────────────────
@@ -6,9 +6,9 @@ const EN = {
6
6
  enabled: "enabled",
7
7
  disabled: "disabled",
8
8
  keepAll: "keep all",
9
- cancel: " Cancel",
10
- back: "x Back",
11
- save: "s Save",
9
+ cancel: "x Cancel",
10
+ back: "b Back",
11
+ save: "s Save",
12
12
 
13
13
  menuTitle: "Pi WebDAV Backup",
14
14
  uploadAllBackups: "⬆️ Back Up All",
@@ -101,9 +101,9 @@ const ZH: Record<TranslationKey, string> = {
101
101
  enabled: "已启用",
102
102
  disabled: "已禁用",
103
103
  keepAll: "全部保留",
104
- cancel: " 取消",
105
- back: "x 返回",
106
- save: "s 保存",
104
+ cancel: "x 取消",
105
+ back: "b 返回",
106
+ save: "s 保存",
107
107
 
108
108
  menuTitle: "Pi WebDAV 备份",
109
109
  uploadAllBackups: "⬆️ 全部备份",
@@ -1,9 +1,35 @@
1
1
  import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
 
4
- import { isProjectAllowed, loadConfig, refreshFooterStatusFromConfig } from "./config";
5
+ import { AGENT_DIR, isProjectAllowed, loadConfig, refreshFooterStatusFromConfig } from "./config";
6
+ import { ensureDir } from "../_shared/json-io";
5
7
  import { registerSyncCommand, uploadSessionProjectArchive } from "./menus";
6
8
 
9
+ /** 退出时自动上传会话归档的最小间隔:频繁重启不应耗尽 maxBackups 把含历史的老归档轮替掉。 */
10
+ export const EXIT_UPLOAD_MIN_INTERVAL_MS = 30 * 60 * 1000;
11
+
12
+ const exitUploadMarkerPath = path.join(AGENT_DIR, "state", "pi-sync-last-exit-upload.txt");
13
+
14
+ /**
15
+ * 判断退出自动上传是否到期。marker 不存在或距上次上传超过 minIntervalMs 时到期。
16
+ * 独立导出便于测试。
17
+ */
18
+ export function exitUploadDue(markerPath: string, nowMs: number, minIntervalMs = EXIT_UPLOAD_MIN_INTERVAL_MS): boolean {
19
+ try {
20
+ const last = Number(fs.readFileSync(markerPath, "utf8").trim());
21
+ if (!Number.isFinite(last) || last <= 0) return true;
22
+ return nowMs - last >= minIntervalMs;
23
+ } catch {
24
+ return true;
25
+ }
26
+ }
27
+
28
+ export function recordExitUpload(markerPath: string, nowMs: number): void {
29
+ ensureDir(path.dirname(markerPath));
30
+ fs.writeFileSync(markerPath, String(nowMs));
31
+ }
32
+
7
33
  export function projectDirFromSessionDir(sessionDir: string | undefined): string | undefined {
8
34
  if (!sessionDir) return undefined;
9
35
  const projectDir = path.basename(sessionDir);
@@ -29,7 +55,10 @@ export default function registerSyncExtension(pi: ExtensionAPI): void {
29
55
  if (!config.webdavUrl || !config.webdavUser || !config.webdavPass) return;
30
56
  const projectDir = projectDirFromSessionDir(ctx.sessionManager.getSessionDir());
31
57
  if (!projectDir || !isProjectAllowed(projectDir, config)) return;
32
- await uploadSessionProjectArchive(ctx, config, projectDir, true);
58
+ const now = Date.now();
59
+ if (!exitUploadDue(exitUploadMarkerPath, now)) return;
60
+ const uploaded = await uploadSessionProjectArchive(ctx, config, projectDir, true);
61
+ if (uploaded) recordExitUpload(exitUploadMarkerPath, now);
33
62
  });
34
63
 
35
64
  registerSyncCommand(pi);
@@ -73,8 +73,9 @@ async function selectAction<T extends string>(
73
73
  ctx: ExtensionCommandContext,
74
74
  title: string,
75
75
  items: SelectItem<T>[],
76
+ initialIndex = 0,
76
77
  ): Promise<T | undefined> {
77
- const selected = await enhancedSelect(ctx, title, items.map((item) => item.label));
78
+ const selected = await enhancedSelect(ctx, title, items.map((item) => item.label), { initialIndex });
78
79
  return items.find((item) => item.label === selected)?.id;
79
80
  }
80
81
 
@@ -135,6 +136,7 @@ function sessionSelectionLabel(config: SyncConfig): string {
135
136
  }
136
137
 
137
138
  async function showSessionProjectSelect(ctx: ExtensionCommandContext, config: SyncConfig): Promise<void> {
139
+ let cursor = 0;
138
140
  while (true) {
139
141
  const language = config.language;
140
142
  const projects = listSessionProjects();
@@ -149,8 +151,9 @@ async function showSessionProjectSelect(ctx: ExtensionCommandContext, config: Sy
149
151
  })),
150
152
  { id: "back", label: t(language, "back") },
151
153
  ];
152
- const action = await selectAction(ctx, t(language, "selectSessionProjects", { mode: modeLabel }), items);
154
+ const action = await selectAction(ctx, t(language, "selectSessionProjects", { mode: modeLabel }), items, cursor);
153
155
  if (!action || action === "back") return;
156
+ cursor = Math.max(0, items.findIndex((item) => item.id === action));
154
157
  if (action === "mode") {
155
158
  config.sessionProjectMode = config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist";
156
159
  } else if (action === "all") {
@@ -182,6 +185,7 @@ export async function showSetupWizard(ctx: ExtensionCommandContext): Promise<boo
182
185
 
183
186
  export async function showConfigureSettings(ctx: ExtensionCommandContext): Promise<void> {
184
187
  const config = cloneConfig(loadConfig());
188
+ let cursor = 0;
185
189
  while (true) {
186
190
  const language = config.language;
187
191
  const items: SelectItem<string>[] = [
@@ -199,8 +203,9 @@ export async function showConfigureSettings(ctx: ExtensionCommandContext): Promi
199
203
  { id: "save", label: t(language, "save") },
200
204
  { id: "back", label: t(language, "back") },
201
205
  ];
202
- const action = await selectAction(ctx, t(language, "configureTitle"), items);
206
+ const action = await selectAction(ctx, t(language, "configureTitle"), items, cursor);
203
207
  if (!action || action === "back") return;
208
+ cursor = Math.max(0, items.findIndex((item) => item.id === action));
204
209
  if (action === "save") {
205
210
  saveConfig(config, ctx);
206
211
  ctx.ui.notify(t(language, "configSaved"), "info");
@@ -361,6 +366,19 @@ async function showRestorePackage(
361
366
  )) return false;
362
367
  }
363
368
  const restored = kind === "pi" ? await extractPiAgentZip(local) : await extractAgentSkillsZip(local);
369
+ if (kind === "pi") {
370
+ try {
371
+ // ctx.reload() 只重载扩展等资源,不会刷新内存中的 ModelRegistry。
372
+ // 禁用网络请求,仅重读刚恢复的 ~/.pi/agent/models.json,使模型选择器立即可见最新目录。
373
+ const refresh = await ctx.modelRegistry.refresh({ allowNetwork: false });
374
+ if (refresh.errors.size > 0) {
375
+ console.warn(`[pi-sync] Model catalog refresh completed with ${refresh.errors.size} error(s)`);
376
+ }
377
+ } catch (error) {
378
+ // 文件已恢复;模型刷新失败不应让整个恢复操作误报失败,重启 Pi 后仍会重新读取 models.json。
379
+ console.warn(`[pi-sync] Failed to refresh restored model catalog: ${error instanceof Error ? error.message : String(error)}`);
380
+ }
381
+ }
364
382
  ctx.ui.notify(t(language, "restoreCompleted", { kind: label, contents: restored.join("\n") }), "info");
365
383
  if (kind === "pi" && reloadAfter && await ctx.ui.confirm(t(language, "reloadRuntimeTitle"), t(language, "reloadRuntimeBody"))) {
366
384
  await ctx.reload();
@@ -15,6 +15,9 @@ async function extractToTemp(archivePath: string, prefix: string): Promise<strin
15
15
  }
16
16
 
17
17
  function assertSafeRestoreParent(root: string, destination: string): void {
18
+ // destination 就是 root 本身(sessions 顶层合并的目标)是安全的:
19
+ // 此时 dirname(destination) 在 root 之外,会被下面的检查误判为路径逃逸。
20
+ if (path.resolve(destination) === path.resolve(root)) return;
18
21
  const relative = path.relative(root, path.dirname(destination));
19
22
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Restore destination escapes root: ${destination}`);
20
23
  let current = root;
@@ -134,13 +137,14 @@ export async function extractAgentSkillsZip(archivePath: string, targetDir = AGE
134
137
  }
135
138
  }
136
139
 
137
- export async function extractSessionsArchiveZip(archivePath: string): Promise<string[]> {
140
+ export async function extractSessionsArchiveZip(archivePath: string, targetDir = SESSIONS_DIR): Promise<string[]> {
138
141
  const tempDir = await extractToTemp(archivePath, "pi_sessions_extract");
139
142
  try {
140
143
  const source = path.join(tempDir, "sessions");
141
144
  if (!fs.existsSync(source)) throw new Error("Archive does not contain sessions/.");
142
- fs.mkdirSync(SESSIONS_DIR, { recursive: true });
143
- const fileCount = copyExtractedTree(source, SESSIONS_DIR, SESSIONS_DIR);
145
+ const target = path.resolve(targetDir);
146
+ fs.mkdirSync(target, { recursive: true });
147
+ const fileCount = copyExtractedTree(source, target, target);
144
148
  return [`Session archive merged: ${fileCount} file(s)`];
145
149
  } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
146
150
  }
@@ -12,7 +12,15 @@ export const WEBDAV_AGENT_SKILLS_DIR = "backup/skills/";
12
12
  export const WEBDAV_SESSIONS_ARCHIVE_DIR = "backup/sessions/";
13
13
 
14
14
  export const ensureTrailingSlash = (url: string): string => url.endsWith("/") ? url : `${url}/`;
15
- export const webdavDirBase = (config: SyncConfig, remoteDir: string): string => ensureTrailingSlash(config.webdavUrl) + remoteDir.replace(/^\/+/, "");
15
+ /**
16
+ * 拼接 WebDAV 目录 URL。remoteDir 逐段 encodeURIComponent,与 ensureWebdavDirectory
17
+ * 的编码一致;否则含中文/空格的 cwd 转义目录在严格 WebDAV 实现上会 400。
18
+ */
19
+ export const webdavDirBase = (config: SyncConfig, remoteDir: string): string =>
20
+ remoteDir.split("/").filter(Boolean).reduce(
21
+ (url, segment) => `${url}${encodeURIComponent(segment)}/`,
22
+ ensureTrailingSlash(config.webdavUrl),
23
+ );
16
24
  export const webdavAuth = (config: SyncConfig): string => "Basic " + Buffer.from(`${config.webdavUser}:${resolvePassword(config.webdavPass)}`).toString("base64");
17
25
 
18
26
  export async function webdavList(url: string, auth: string, ctx: ExtensionContext, filter?: (name: string) => boolean): Promise<string[]> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-sync",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "WebDAV archive backup and restore for Pi agent data, shared skills, and per-project sessions",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/wuyaos/pi-packages.git",
14
+ "url": "git+https://github.com/wuyaos/pi-packages.git",
15
15
  "directory": "pi-sync"
16
16
  },
17
17
  "publishConfig": {