@wuyaos/pi-sync 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh-CN.md CHANGED
@@ -4,202 +4,211 @@
4
4
 
5
5
  [![pi package](https://img.shields.io/badge/pi-package-blue)](https://github.com/earendil-works/pi-coding-agent)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
7
- [![release](https://img.shields.io/github/v/release/wuyaos/pi-packages?display_name=tag&sort=semver)](https://github.com/wuyaos/pi-packages/releases)
8
7
 
9
- 面向 [Pi](https://github.com/earendil-works/pi-coding-agent) 的 WebDAV 配置同步工具 —— 跨机器备份与恢复 **models**、**settings**、**skills**、**extensions** 以及选定的 **session 项目**。
8
+ 面向 [Pi](https://github.com/earendil-works/pi-coding-agent) 的 WebDAV 归档备份与恢复工具。
10
9
 
11
- Pi 里输入 `/sync`,从菜单选择操作。一台机器上传,另一台下载并恢复。
10
+ `pi-sync` Pi agent 数据、共享 Skills 和项目会话创建有版本的 `.tar.xz` 归档。它现在是**纯归档模式**:没有实时上传、每轮 hook、定时同步或多机合并冲突。
12
11
 
13
- <p align="center">
14
- <img src="docs/sync-menu.png" alt="Pi WebDAV Synchronization 菜单" width="720" />
15
- </p>
12
+ ## 特性
16
13
 
17
- <p align="center"><sub><b>Pi WebDAV Synchronization</b> —— 输入 <code>/sync</code> 后的交互菜单</sub></p>
18
-
19
- ## 为什么需要它
20
-
21
- 如果你在多台 PC / WSL / 服务器上使用 Pi,手工重装 models、skills、extensions 很痛苦。`pi-sync` 会把 agent 主目录打包成带时间戳的 zip,上传到任意 WebDAV 目录,并在恢复时保留本地安全备份。
14
+ - 直接打包 `~/.pi/agent`,不创建完整临时副本
15
+ - 通过可配置黑名单排除可重装或临时目录
16
+ - `~/.agents/skills` 独立、可选归档
17
+ - 会话按项目独立归档
18
+ - 退出 Pi 时自动归档当前项目
19
+ - 一次操作备份或恢复所有已启用类别
20
+ - `/sync` 菜单支持英文/简体中文即时切换
21
+ - WebDAV 上传和下载使用流式传输,不把整个归档读入内存
22
+ - 拒绝危险路径、符号链接、特殊节点和不安全恢复目标
22
23
 
23
24
  ## 安装
24
25
 
25
- 需要 [Pi coding agent](https://github.com/earendil-works/pi-coding-agent),以及可用的 WebDAV(TeraCLOUD、坚果云、Nextcloud、ownCloud、自建等)。
26
-
27
- `pi-sync` 是 [wuyaos/pi-packages](https://github.com/wuyaos/pi-packages) monorepo 的子包。直接安装整个仓库会加载所有子包:
26
+ npm 安装:
28
27
 
29
28
  ```bash
30
- pi install git:github.com/wuyaos/pi-packages
29
+ pi install npm:@wuyaos/pi-sync
31
30
  ```
32
31
 
33
- 只想加载 **pi-sync** 时,在 `~/.pi/agent/settings.json` 用 object 形式筛选:
32
+ 或安装整个 monorepo:
34
33
 
35
- ```json
36
- {
37
- "packages": [
38
- {
39
- "source": "git:github.com/wuyaos/pi-packages",
40
- "extensions": ["pi-sync/extensions/*.ts"],
41
- "themes": []
42
- }
43
- ]
44
- }
34
+ ```bash
35
+ pi install git:github.com/wuyaos/pi-packages
45
36
  ```
46
37
 
47
- 然后重启 Pi,或执行 `/reload`。
38
+ 安装后重启 Pi 或执行 `/reload`。
48
39
 
49
40
  ## 用法
50
41
 
51
- Pi 中输入 **`/sync`**。没有命令行子命令 —— 全部通过交互菜单完成:
42
+ 运行 `/sync`,交互菜单提供:
52
43
 
53
- | 菜单项 | 作用 |
54
- |--------|------|
55
- | ☁️ **Upload Backup (Backup to cloud)** | 打包当前配置并上传到 WebDAV |
56
- | 📥 **Download Backup (Restore from cloud)** | 列出云端备份,下载并在确认后恢复 |
57
- | ⚙️ **Configure Sync Settings** | 配置 WebDAV 地址 / 用户 / 密码,以及同步范围 |
58
- | ❌ **Cancel** | 退出菜单 |
44
+ - **全部备份** / **全部恢复(最新)**
45
+ - 单独上传或恢复 **Pi 备份**
46
+ - 单独上传或恢复 **Skills 备份**
47
+ - 单独上传或恢复 **会话归档**
48
+ - 配置备份设置
49
+ - 中英文菜单切换
59
50
 
60
- TUI 提示:`↵` 选择 · `↑↓` 导航 · `Esc` 取消。
51
+ 首次运行若缺少 WebDAV 地址、用户名或密码,会自动打开配置向导。
61
52
 
62
- ### 首次配置
53
+ ## 归档模型
63
54
 
64
- ```bash
65
- # 1. 安装
66
- pi install git:github.com/wuyaos/pi-packages
55
+ ### Pi 备份
67
56
 
68
- # 2. 打开菜单(若尚未配置 WebDAV,会先进入设置向导)
69
- /sync
57
+ Pi 归档直接打包 `~/.pi/agent`。默认黑名单:
70
58
 
71
- # 3. 如需修改:Configure Sync Settings
72
- # 填写 URL / 用户名 / 密码
73
- # 建议:密码填 $PI_WEBDAV_PASS,并在 shell 中 export 该环境变量
59
+ ```json
60
+ ["npm", "git", "sessions", "state", "tmp", "webui-rpc-supervisor", "vstack"]
61
+ ```
62
+
63
+ 这样会保留配置与扩展状态,同时排除可重装的包源码、临时扩展缓存、单独归档的会话、工作区历史、后台任务状态和 Web UI RPC 运行时 socket。除非显式加入黑名单,`config/sync.json` 也会进入归档。
64
+
65
+ 归档名称:
74
66
 
75
- # 4. 主力机 → Upload Backup (Backup to cloud)
76
- # 5. 新机器(安装并配置后)→ Download Backup (Restore from cloud)
67
+ ```text
68
+ backup/pi/pi_agent_<platform>_<timestamp>.tar.xz
77
69
  ```
78
70
 
79
- ### 会同步哪些内容
71
+ ### 共享 Skills 备份
80
72
 
81
- | 组件 | 默认 | 说明 |
82
- |------|------|------|
83
- | Config | 开 | `models.json`、`settings.json`、`auth.json` |
84
- | Skills | 开 | 整个 `~/.pi/agent/skills` |
85
- | Extensions | 开 | `~/.pi/agent/extensions`(zip 中会排除 sync 插件自身) |
86
- | Sessions | 关 | `~/.pi/agent/sessions/` 下按项目分目录的会话历史;在 **Configure Sync Settings → Session Projects** 中勾选要同步的项目 |
73
+ `~/.agents/skills` 使用独立归档,默认关闭:
87
74
 
88
- 可在 **Configure Sync Settings** 中分别开关。
75
+ ```text
76
+ backup/skills/agent_skills_<timestamp>.tar.xz
77
+ ```
89
78
 
90
- ### Sessions(可选)
79
+ ### 会话归档
91
80
 
92
- 会话历史按项目 cwd 存放在 `~/.pi/agent/sessions/<projectDir>/`。**Session Projects** 子菜单会列出本机所有项目目录,勾选你想备份的那些。
81
+ 会话按项目归档,不再进行实时同步,也没有 `_latest.json` marker:
93
82
 
94
- - 在 **Configure Sync Settings** 中打开 **Backup Sessions**。
95
- - 打开 **Session Projects** 逐个勾选项目(可用 **Select All** / **Reset list** 快捷全选/清空)。
96
- - 列表模式(可切换):
97
- - **白名单模式**:只备份勾选的项目,空列表 = 全部不备份。
98
- - **黑名单模式**:跳过勾选的项目,空列表 = 全部备份。
99
- - 恢复时会以 *合并* 方式写入本地 `~/.pi/agent/sessions/`——会话文件名为唯一的时间戳+uuid,不会覆盖或删除本地已有会话。
83
+ ```text
84
+ backup/sessions/<projectDir>/sessions_<platform>_<timestamp>.tar.xz
85
+ ```
86
+
87
+ 项目选择支持:
100
88
 
101
- > 注意:项目目录名由项目路径编码而来,备份在 A 机器上制作,恢复到 B 机器时只会落到相同项目路径对应的项目目录中。
89
+ - **白名单**:仅归档列表中的项目;空列表表示全部不归档。
90
+ - **黑名单**:排除列表中的项目;空列表表示归档全部项目。
102
91
 
103
- ### 备份文件名
92
+ 开启 `backupOnExit` 后,Pi 在 `session_shutdown` 时归档当前允许的项目;仍可通过 `/sync` 手动归档。
104
93
 
105
- 归档文件形如:
94
+ ## WebDAV 目录结构
106
95
 
107
96
  ```text
108
- pi_sync_backup_2026-7-14_20260714120000_windows11.zip
97
+ <webdavUrl>/
98
+ └── backup/
99
+ ├── pi/
100
+ │ └── pi_agent_<platform>_<timestamp>.tar.xz
101
+ ├── skills/
102
+ │ └── agent_skills_<timestamp>.tar.xz
103
+ └── sessions/
104
+ └── <projectDir>/
105
+ └── sessions_<platform>_<timestamp>.tar.xz
106
+ ```
107
+
108
+ `maxBackups` 在每个备份目录内独立生效;设为 `0` 表示全部保留。
109
+
110
+ ## 配置
111
+
112
+ 配置文件为 `~/.pi/agent/config/sync.json`:
113
+
114
+ ```json
115
+ {
116
+ "webdavUrl": "https://example.com/dav/pi",
117
+ "webdavUser": "user",
118
+ "webdavPass": "$PI_WEBDAV_PASS",
119
+ "language": "zh",
120
+ "backupProviders": true,
121
+ "backupSessions": true,
122
+ "backupAgentSkills": false,
123
+ "piExcludePaths": ["npm", "git", "sessions", "state", "tmp", "webui-rpc-supervisor", "vstack"],
124
+ "backupOnExit": true,
125
+ "sessionProjectMode": "blacklist",
126
+ "sessionProjects": [],
127
+ "maxBackups": 10
128
+ }
109
129
  ```
110
130
 
111
- 末尾的平台标签(`windows11` / `windows10` / `macos` / `linux`)标明该备份由哪类主机生成。
131
+ 密码支持环境变量引用(如 `$PI_WEBDAV_PASS`)。未设置 `language` 时,会读取 `settings.json piSwitch.language`,无法识别则默认英文。
112
132
 
113
- ### 恢复时的安全机制
133
+ ## 恢复行为与安全
114
134
 
115
- - 覆盖前,已有配置文件会生成带时间戳的 `.bak` 副本
116
- - 已有 skills / extensions 目录会先改名为 `*-backup-<timestamp>`,再替换/合并
117
- - 恢复前会展示计划,并要求确认
118
- - 恢复成功后可选择 reload agent runtime,以应用 skills / extensions
135
+ - 检查归档中的绝对路径和目录穿越。
136
+ - 解压前拒绝符号链接和非普通文件节点。
137
+ - 先解压到临时目录,再合并到目标目录。
138
+ - 若目标路径包含符号链接或类型冲突则拒绝恢复。
139
+ - Pi 恢复只改写归档中存在的文件,黑名单目录保持原样。
140
+ - Skills 恢复前会把现有 `~/.agents/skills` 移到带时间戳的备份目录。
141
+ - 会话恢复以合并方式写入 `~/.pi/agent/sessions`。
142
+ - Pi 恢复前展示计划,完成后可选择 `/reload`。
119
143
 
120
- ## 新机引导(Windows,尚未安装 Pi)
144
+ 归档可能包含 WebDAV 凭据、API Key、provider 配置及其他秘密,应把 WebDAV 存储视为敏感数据。
121
145
 
122
- 若还没装 Pi,也可先用辅助脚本拉取最新 zip:
146
+ ## Windows 新机引导
147
+
148
+ `pi-bootstrap.ps1` 会从 `backup/pi/` 下载最新归档,并合并到 `%USERPROFILE%\.pi\agent`:
149
+
150
+ > **仅限可信归档:** 此 bootstrap 脚本不执行 pi-sync TypeScript 恢复流程的路径和符号链接校验。日常恢复请使用 `/sync`,仅在可信 WebDAV 端点和可信归档场景下使用该脚本。
123
151
 
124
152
  ```powershell
125
- # 优先用环境变量,避免密钥进入 shell 历史
126
- $env:PI_WEBDAV_URL = "https://your-webdav.example/dav/Pi"
127
- $env:PI_WEBDAV_USER = "your-user"
128
- $env:PI_WEBDAV_PASS = "your-app-password"
153
+ $env:PI_WEBDAV_URL = "https://example.com/dav/pi"
154
+ $env:PI_WEBDAV_USER = "user"
155
+ $env:PI_WEBDAV_PASS = "app-password"
129
156
  .\pi-bootstrap.ps1
130
157
  ```
131
158
 
132
- 或使用占位符一行命令(运行前请替换):
159
+ 该脚本用于可信归档和新机恢复。恢复后需要安装/更新 packages,以重建被排除的 `npm/` 和 `git/` 目录。
133
160
 
134
- ```powershell
135
- $url="https://your-webdav.example/dav/Pi"; $user="your-user"; $pass="your-app-password"
136
- $pair="$user`:$pass"; $auth=[Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair))
137
- $resp=Invoke-RestMethod -Uri $url -Method PROPFIND -Headers @{Authorization="Basic $auth";Depth="1"} -ContentType "application/xml"
138
- $files=([regex]'<d:href>([^<]+)</d:href>').Matches($resp) | %{$_.Groups[1].Value} | ?{$_ -match "pi_sync_backup_.*\.zip$"} | Sort-Object -Descending
139
- $latest=$files[0]; $name=Split-Path $latest -Leaf
140
- Invoke-WebRequest -Uri "$url/$name" -Headers @{Authorization="Basic $auth"} -OutFile "$env:TEMP\$name"
141
- ```
161
+ ## 故障排查
142
162
 
143
- 之后安装 Pi,后续更新用 `/sync` **Download Backup** 即可。
163
+ | 现象 | 处理 |
164
+ |---|---|
165
+ | HTTP 401 / 403 | 检查 WebDAV 地址,并使用应用专用密码。 |
166
+ | PROPFIND 失败 | 确认服务端支持 `Depth: 1` 的 WebDAV `PROPFIND`。 |
167
+ | tar 报错 | 安装支持 xz 的 `tar`。 |
168
+ | 退出时未备份 | 检查 `backupOnExit`、`backupSessions`、项目黑白名单和 WebDAV 凭据。 |
169
+ | Pi 恢复后插件缺失 | 运行 `pi update --extensions`,或按 `settings.json` 重新安装 packages。 |
144
170
 
145
- ## 安全建议
171
+ ## 开发验证
146
172
 
147
- - WebDAV 凭证保存在本机 `~/.pi/agent/sync_config.json`
148
- - 优先使用**应用专用密码**(不要用主账号密码)
149
- - 更推荐环境变量引用:界面里密码填 `$PI_WEBDAV_PASS`,再在 shell profile 中 export
150
- - 若开启相关选项,备份可能包含 `auth.json` / API key —— 请把 WebDAV 目录当敏感数据对待
151
- - 切勿把真实 WebDAV 地址与凭证提交进 git
173
+ ```bash
174
+ cd /mnt/d/work/project/person/pi-packages
175
+ node --import tsx --test pi-sync/extensions/sync/*.test.ts
176
+ npm run typecheck
177
+ ```
152
178
 
153
- ## 故障排查
179
+ monorepo 类型检查可能报告其他包的既有错误;pi-sync 自身错误路径以 `pi-sync/` 开头。
154
180
 
155
- | 现象 | 处理 |
156
- |------|------|
157
- | HTTP 401 / 403 | 检查用户名密码;改用应用专用密码;确认 URL 含正确 DAV 路径 |
158
- | PROPFIND 失败 / 列表为空 | 服务端可能禁用 PROPFIND;换 WebDAV 提供商;确认允许 Depth:1 |
159
- | tar / zip 报错 | PATH 中需要可用的 `tar`(Windows 10+ 自带;Git Bash / WSL 亦可) |
160
- | 恢复覆盖了本地内容 | 在 agent 目录旁查找 `*.bak-*` 与 `skills-backup-*` / `extensions-backup-*` |
161
- | 恢复后插件不见了 | 重新执行 `pi install git:github.com/wuyaos/pi-packages` —— 归档会排除 sync 包自身 |
181
+ ### WebDAV 烟雾测试
162
182
 
163
- ## 目录结构
183
+ 仓库内的 smoke harness 会创建唯一命名的临时 Pi 与会话归档,执行上传、列举、下载和校验,最后删除测试对象;它不会输出凭据,且默认拒绝写入:
164
184
 
165
- ```text
166
- pi-sync/
167
- package.json
168
- LICENSE
169
- README.md
170
- README.zh-CN.md
171
- pi-bootstrap.ps1
172
- docs/
173
- sync-menu.png # /sync 菜单截图
174
- extensions/
175
- sync/
176
- index.ts # /sync 命令
177
- _shared/
178
- json-io.ts
179
- enhanced-select.ts
180
- spawn.ts
181
- fetch-utils.ts
182
- box-drawing.ts
185
+ ```bash
186
+ cd /mnt/d/work/project/person/pi-packages
187
+ PI_SYNC_SMOKE_WRITE=1 bash --noprofile --norc pi-sync/scripts/verify.sh
183
188
  ```
184
189
 
190
+ `verify.sh` 还会执行单元测试、严格的 pi-sync 类型检查、扩展加载检查、发布白名单检查和 diff 检查。它读取 `~/.pi/agent/config/sync.json`;可通过 `PI_SYNC_SMOKE_PROJECT` 指定一个允许归档的本地会话目录名(如 `--home-user--`)。
191
+
185
192
  ## 更新日志
186
193
 
187
- ### v1.0.1
194
+ ### v1.2.0
195
+
196
+ - 从实时同步改为有版本的纯归档备份/恢复
197
+ - 新增 Pi 主目录直接 tar 打包与黑名单排除
198
+ - 分离 Pi、共享 Skills 和项目会话归档
199
+ - 新增退出时会话归档、手动全部备份/全部恢复
200
+ - 新增中英文菜单切换
201
+ - 新增 WebDAV 流式传输、配置缓存和归档/恢复安全检查
202
+ - 删除实时同步、定时同步、custom-path、memory 和 legacy 单体代码
203
+
204
+ ### v1.1.1
188
205
 
189
- - 备份 zip 文件名增加主机平台标签(`windows11` / `macos` / `linux` 等)
190
- - 从 bootstrap 脚本示例中移除真实凭证
191
- - 增加 MIT `LICENSE`,扩充 README(安全、恢复保护、故障排查、菜单截图)
206
+ - 缓存 WebDAV 目录创建结果与已加载配置,减少重复 I/O
192
207
 
193
208
  ### v1.0.0
194
209
 
195
- - 首次公开发布:基于 WebDAV 的交互式 `/sync` 菜单
196
- - Upload Backup · Download Backup · Configure Sync Settings
197
- - Windows 新机引导脚本
210
+ - 初始 WebDAV 备份/恢复版本
198
211
 
199
212
  ## 许可证
200
213
 
201
214
  MIT — 见 [LICENSE](./LICENSE)。
202
-
203
- ## 致谢
204
-
205
- 本开源项目已链接并获 [LINUX DO](https://linux.do) 社区认可。
@@ -4,12 +4,10 @@ import * as path from "node:path";
4
4
  import { runCommand } from "../_shared/spawn";
5
5
  import {
6
6
  AGENT_DIR,
7
- AGENT_ROOT_MARKDOWN_FILES,
8
7
  AGENT_SKILLS_DIR,
9
- MEMORY_MARKDOWN_FILES,
10
8
  SESSIONS_DIR,
11
9
  ensureDir,
12
- isProjectAllowed,
10
+ normalizePiExcludePaths,
13
11
  type ManifestFile,
14
12
  type SyncConfig,
15
13
  } from "./config";
@@ -41,9 +39,17 @@ export async function runTar(args: string[], options: { capture?: boolean; timeo
41
39
  return options.capture ? result.stdout : "";
42
40
  }
43
41
 
42
+ function lstatRegularOrDirectory(src: string): fs.Stats {
43
+ const stats = fs.lstatSync(src);
44
+ if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) {
45
+ throw new Error(`Refusing unsafe filesystem entry: ${src}`);
46
+ }
47
+ return stats;
48
+ }
49
+
44
50
  export function copyRecursiveSync(src: string, dest: string): void {
45
51
  if (!fs.existsSync(src)) return;
46
- const stats = fs.statSync(src);
52
+ const stats = lstatRegularOrDirectory(src);
47
53
  if (stats.isDirectory()) {
48
54
  ensureDir(dest);
49
55
  for (const child of fs.readdirSync(src)) copyRecursiveSync(path.join(src, child), path.join(dest, child));
@@ -55,7 +61,7 @@ export function copyRecursiveSync(src: string, dest: string): void {
55
61
 
56
62
  export function copyRecursiveSyncFiltered(src: string, dest: string, include: (name: string, isDirectory: boolean) => boolean): void {
57
63
  if (!fs.existsSync(src)) return;
58
- const stats = fs.statSync(src);
64
+ const stats = lstatRegularOrDirectory(src);
59
65
  if (!include(path.basename(src), stats.isDirectory())) return;
60
66
  if (stats.isDirectory()) {
61
67
  ensureDir(dest);
@@ -68,12 +74,18 @@ export function copyRecursiveSyncFiltered(src: string, dest: string, include: (n
68
74
 
69
75
  export function collectManifest(dir: string, archivePrefix: string, sourcePrefix: string, files: ManifestFile[]): void {
70
76
  if (!fs.existsSync(dir)) return;
77
+ const stats = lstatRegularOrDirectory(dir);
78
+ if (stats.isFile()) {
79
+ files.push({ archive: archivePrefix, source: sourcePrefix });
80
+ return;
81
+ }
71
82
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
72
83
  const archivePath = `${archivePrefix}/${entry.name}`;
73
84
  const sourcePath = sourcePrefix ? `${sourcePrefix}/${entry.name}` : entry.name;
74
85
  const full = path.join(dir, entry.name);
75
86
  if (entry.isDirectory()) collectManifest(full, archivePath, sourcePath, files);
76
- else files.push({ archive: archivePath, source: sourcePath });
87
+ else if (entry.isFile()) files.push({ archive: archivePath, source: sourcePath });
88
+ else throw new Error(`Refusing unsafe filesystem entry: ${full}`);
77
89
  }
78
90
  }
79
91
 
@@ -98,27 +110,36 @@ export async function listArchiveEntries(archivePath: string): Promise<string[]>
98
110
  .filter((entry) => entry && entry !== ".");
99
111
  }
100
112
 
113
+ /**
114
+ * `tar -t` only prints names, so it cannot distinguish regular files from
115
+ * links or device nodes. Ask tar for the entry type before extraction.
116
+ * The POSIX tar permission field starts with `-` (file) or `d` (directory);
117
+ * every other type is rejected.
118
+ */
119
+ export async function validateArchiveEntryTypes(archivePath: string): Promise<void> {
120
+ const lines = (await runTar(["-t", "-v", "-f", archivePath], { capture: true }))
121
+ .split(/\r?\n/)
122
+ .filter(Boolean);
123
+ if (lines.length === 0) throw new Error("Backup archive is empty or unreadable");
124
+ for (const line of lines) {
125
+ const entryType = line[0];
126
+ if (entryType !== "-" && entryType !== "d") {
127
+ throw new Error(`Unsafe archive entry type rejected: ${entryType ?? "unknown"}`);
128
+ }
129
+ }
130
+ }
131
+
101
132
  export function validateArchiveEntries(entries: string[]): void {
102
- const allowed = new Set(["config", "skills", "extensions", "sessions", "memory", "agent-skills", "manifest.json"]);
103
- const legacyConfigFiles = new Set(["models.json", "settings.json", "auth.json"]);
104
- const rootMarkdownFiles = new Set<string>(AGENT_ROOT_MARKDOWN_FILES);
105
133
  if (entries.length === 0) throw new Error("Backup archive is empty or unreadable");
106
134
  for (const entry of entries) {
107
135
  const parts = entry.split("/");
108
- if (entry.startsWith("/") || /^[a-zA-Z]:\//.test(entry) || parts.includes("..") || !allowed.has(parts[0]!)) {
136
+ if (
137
+ entry.startsWith("/")
138
+ || /^[a-zA-Z]:\//.test(entry)
139
+ || parts.some((part) => !part || part === "." || part === "..")
140
+ ) {
109
141
  throw new Error(`Unsafe archive path rejected: ${entry}`);
110
142
  }
111
- if (parts[0] !== "config" || !parts[1]) continue;
112
- if (parts[1] === "root") {
113
- if (!parts[2]) continue;
114
- if (parts.length !== 3 || (!parts[2].endsWith(".json") && !rootMarkdownFiles.has(parts[2]))) {
115
- throw new Error(`Unexpected root config file rejected: ${entry}`);
116
- }
117
- } else if (parts[1] === "sub") {
118
- continue;
119
- } else if (parts.length !== 2 || !legacyConfigFiles.has(parts[1])) {
120
- throw new Error(`Unexpected config file rejected: ${entry}`);
121
- }
122
143
  }
123
144
  }
124
145
 
@@ -127,67 +148,53 @@ export async function packTemporaryArchive(tempDir: string, archivePath: string)
127
148
  await runTar(["-J", "-c", "-f", archivePath, "-C", tempDir, "."]);
128
149
  }
129
150
 
130
- export async function createConfigZip(config: SyncConfig, archivePath: string): Promise<string[]> {
131
- const tempDir = path.join(os.tmpdir(), `pi_config_temp_${Date.now()}`);
132
- const rootDir = path.join(tempDir, "config", "root");
133
- const subDir = path.join(tempDir, "config", "sub");
134
- const manifest: ManifestFile[] = [];
135
- const contents: string[] = [];
136
- ensureDir(rootDir);
137
- ensureDir(subDir);
138
- try {
139
- if (config.backupProviders) {
140
- const rootNames = fs.readdirSync(AGENT_DIR).filter((name) => name.endsWith(".json"));
141
- for (const name of AGENT_ROOT_MARKDOWN_FILES) if (!rootNames.includes(name)) rootNames.push(name);
142
- for (const name of rootNames) {
143
- const src = path.join(AGENT_DIR, name);
144
- if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
145
- fs.copyFileSync(src, path.join(rootDir, name));
146
- manifest.push({ archive: `config/root/${name}`, source: name });
147
- contents.push(`Config: ${name}`);
148
- }
149
- const configDir = path.join(AGENT_DIR, "config");
150
- if (fs.existsSync(configDir)) {
151
- copyRecursiveSync(configDir, subDir);
152
- collectManifest(subDir, "config/sub", "config", manifest);
153
- contents.push("Config directory");
154
- }
155
- }
156
- if (config.backupExtensions) {
157
- const source = path.join(AGENT_DIR, "extensions");
158
- const dest = path.join(tempDir, "extensions");
159
- if (fs.existsSync(source)) {
160
- copyRecursiveSync(source, dest);
161
- fs.rmSync(path.join(dest, "sync"), { recursive: true, force: true });
162
- collectManifest(dest, "extensions", "extensions", manifest);
163
- contents.push("Extensions directory");
164
- }
165
- }
166
- if (manifest.length === 0) throw new Error("No config or extension files found to back up.");
167
- writeManifest(tempDir, AGENT_DIR, manifest);
168
- await packTemporaryArchive(tempDir, archivePath);
169
- return contents;
170
- } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
151
+ function isExcluded(relativePath: string, exclusions: readonly string[]): boolean {
152
+ return exclusions.some((excluded) => relativePath === excluded || relativePath.startsWith(`${excluded}/`));
171
153
  }
172
154
 
173
- export async function createMemoryZip(archivePath: string): Promise<string[]> {
174
- const sourceDir = path.join(AGENT_DIR, "pi-hermes-memory");
175
- const tempDir = path.join(os.tmpdir(), `pi_memory_temp_${Date.now()}`);
176
- const destDir = path.join(tempDir, "memory");
177
- const manifest: ManifestFile[] = [];
178
- ensureDir(destDir);
179
- try {
180
- for (const name of MEMORY_MARKDOWN_FILES) {
181
- const src = path.join(sourceDir, name);
182
- if (!fs.existsSync(src)) continue;
183
- fs.copyFileSync(src, path.join(destDir, name));
184
- manifest.push({ archive: `memory/${name}`, source: `pi-hermes-memory/${name}` });
155
+ function validatePiAgentTree(sourceDir: string, exclusions: readonly string[]): number {
156
+ const root = lstatRegularOrDirectory(sourceDir);
157
+ if (!root.isDirectory()) throw new Error(`Pi agent path is not a directory: ${sourceDir}`);
158
+ let fileCount = 0;
159
+ const visit = (dir: string, relativeDir: string): void => {
160
+ for (const name of fs.readdirSync(dir)) {
161
+ const relativePath = relativeDir ? `${relativeDir}/${name}` : name;
162
+ if (isExcluded(relativePath, exclusions)) continue;
163
+ const fullPath = path.join(dir, name);
164
+ const stats = lstatRegularOrDirectory(fullPath);
165
+ if (stats.isDirectory()) visit(fullPath, relativePath);
166
+ else fileCount += 1;
185
167
  }
186
- if (manifest.length === 0) throw new Error("No durable pi-hermes-memory markdown files found.");
187
- writeManifest(tempDir, AGENT_DIR, manifest);
188
- await packTemporaryArchive(tempDir, archivePath);
189
- return manifest.map((item) => `Memory: ${path.basename(item.source)}`);
190
- } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
168
+ };
169
+ visit(sourceDir, "");
170
+ return fileCount;
171
+ }
172
+
173
+ /**
174
+ * Pack ~/.pi/agent directly without first copying it to a staging directory.
175
+ * A lightweight filesystem scan rejects links/special nodes before tar runs;
176
+ * excluded install/state trees are skipped during both scan and compression.
177
+ */
178
+ export async function createPiAgentZip(
179
+ config: Pick<SyncConfig, "piExcludePaths">,
180
+ archivePath: string,
181
+ sourceDir = AGENT_DIR,
182
+ ): Promise<string[]> {
183
+ const source = path.resolve(sourceDir);
184
+ const output = path.resolve(archivePath);
185
+ if (output === source || output.startsWith(`${source}${path.sep}`)) {
186
+ throw new Error("Pi backup archive must be written outside ~/.pi/agent.");
187
+ }
188
+ const exclusions = normalizePiExcludePaths(config.piExcludePaths);
189
+ const fileCount = validatePiAgentTree(source, exclusions);
190
+ if (fileCount === 0) throw new Error("No Pi agent files found to back up.");
191
+ const excludeArgs = exclusions.map((relativePath) => `--exclude=./${relativePath}`);
192
+ await yieldToUI();
193
+ await runTar(["-J", "-c", "-f", output, ...excludeArgs, "-C", source, "."]);
194
+ return [
195
+ `Pi agent: ${fileCount} file(s)`,
196
+ `Excluded: ${exclusions.length ? exclusions.join(", ") : "none"}`,
197
+ ];
191
198
  }
192
199
 
193
200
  export async function createAgentSkillsZip(archivePath: string): Promise<string[]> {
@@ -220,45 +227,3 @@ export async function createSessionsArchiveZip(projectDir: string, archivePath:
220
227
  return [`Sessions: ${projectDir} (${manifest.length} file(s))`];
221
228
  } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
222
229
  }
223
-
224
- export async function createLegacyZip(config: SyncConfig, archivePath: string): Promise<string[]> {
225
- const tempDir = path.join(os.tmpdir(), `pi_sync_temp_${Date.now()}`);
226
- const manifest: ManifestFile[] = [];
227
- const contents: string[] = [];
228
- ensureDir(tempDir);
229
- try {
230
- if (config.backupProviders) {
231
- const configDir = path.join(tempDir, "config");
232
- ensureDir(configDir);
233
- for (const name of ["models.json", "settings.json", "auth.json"]) {
234
- const src = path.join(AGENT_DIR, name);
235
- if (!fs.existsSync(src)) continue;
236
- fs.copyFileSync(src, path.join(configDir, name));
237
- manifest.push({ archive: `config/${name}`, source: name });
238
- contents.push(`Config: ${name}`);
239
- }
240
- }
241
- if (config.backupSkills) {
242
- const src = path.join(AGENT_DIR, "skills"), dest = path.join(tempDir, "skills");
243
- if (fs.existsSync(src)) { copyRecursiveSync(src, dest); collectManifest(dest, "skills", "skills", manifest); contents.push("Skills Directory"); }
244
- }
245
- if (config.backupExtensions) {
246
- const src = path.join(AGENT_DIR, "extensions"), dest = path.join(tempDir, "extensions");
247
- if (fs.existsSync(src)) { copyRecursiveSync(src, dest); fs.rmSync(path.join(dest, "sync"), { recursive: true, force: true }); collectManifest(dest, "extensions", "extensions", manifest); contents.push("Extensions Directory"); }
248
- }
249
- if (config.backupSessions) {
250
- for (const entry of fs.existsSync(SESSIONS_DIR) ? fs.readdirSync(SESSIONS_DIR, { withFileTypes: true }) : []) {
251
- if (!entry.isDirectory()) continue;
252
- if (!isProjectAllowed(entry.name, config)) continue;
253
- const dest = path.join(tempDir, "sessions", entry.name);
254
- copyRecursiveSync(path.join(SESSIONS_DIR, entry.name), dest);
255
- }
256
- const sessionsDest = path.join(tempDir, "sessions");
257
- if (fs.existsSync(sessionsDest)) { collectManifest(sessionsDest, "sessions", "sessions", manifest); contents.push("Sessions"); }
258
- }
259
- if (manifest.length === 0) throw new Error("No components selected or found to backup.");
260
- writeManifest(tempDir, AGENT_DIR, manifest);
261
- await packTemporaryArchive(tempDir, archivePath);
262
- return contents;
263
- } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
264
- }