@wuyaos/pi-sync 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROMO.md +37 -73
- package/README.md +141 -132
- package/README.zh-CN.md +141 -132
- package/extensions/sync/archive.ts +86 -121
- package/extensions/sync/config.ts +57 -27
- package/extensions/sync/i18n.ts +203 -0
- package/extensions/sync/index.ts +20 -43
- package/extensions/sync/menus.ts +472 -171
- package/extensions/sync/restore.ts +108 -102
- package/extensions/sync/webdav.ts +33 -12
- package/package.json +13 -5
- package/pi-bootstrap.ps1 +33 -74
- package/docs/sync-menu.png +0 -0
- package/extensions/sync/session-sync.ts +0 -231
package/README.zh-CN.md
CHANGED
|
@@ -4,202 +4,211 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/earendil-works/pi-coding-agent)
|
|
6
6
|
[](./LICENSE)
|
|
7
|
-
[](https://github.com/wuyaos/pi-packages/releases)
|
|
8
7
|
|
|
9
|
-
面向 [Pi](https://github.com/earendil-works/pi-coding-agent) 的 WebDAV
|
|
8
|
+
面向 [Pi](https://github.com/earendil-works/pi-coding-agent) 的 WebDAV 归档备份与恢复工具。
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
`pi-sync` 为 Pi agent 数据、共享 Skills 和项目会话创建有版本的 `.tar.xz` 归档。它现在是**纯归档模式**:没有实时上传、每轮 hook、定时同步或多机合并冲突。
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
<img src="docs/sync-menu.png" alt="Pi WebDAV Synchronization 菜单" width="720" />
|
|
15
|
-
</p>
|
|
12
|
+
## 特性
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
- 直接打包 `~/.pi/agent`,不创建完整临时副本
|
|
15
|
+
- 通过可配置黑名单排除可重装或临时目录
|
|
16
|
+
- `~/.agents/skills` 独立、可选归档
|
|
17
|
+
- 会话按项目独立归档
|
|
18
|
+
- 退出 Pi 时自动归档当前项目
|
|
19
|
+
- 一次操作备份或恢复所有已启用类别
|
|
20
|
+
- `/sync` 菜单支持英文/简体中文即时切换
|
|
21
|
+
- WebDAV 上传和下载使用流式传输,不把整个归档读入内存
|
|
22
|
+
- 拒绝危险路径、符号链接、特殊节点和不安全恢复目标
|
|
22
23
|
|
|
23
24
|
## 安装
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`pi-sync` 是 [wuyaos/pi-packages](https://github.com/wuyaos/pi-packages) monorepo 的子包。直接安装整个仓库会加载所有子包:
|
|
26
|
+
从 npm 安装:
|
|
28
27
|
|
|
29
28
|
```bash
|
|
30
|
-
pi install
|
|
29
|
+
pi install npm:@wuyaos/pi-sync
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
或安装整个 monorepo:
|
|
34
33
|
|
|
35
|
-
```
|
|
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
|
-
|
|
38
|
+
安装后重启 Pi 或执行 `/reload`。
|
|
48
39
|
|
|
49
40
|
## 用法
|
|
50
41
|
|
|
51
|
-
|
|
42
|
+
运行 `/sync`,交互菜单提供:
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
44
|
+
- **全部备份** / **全部恢复(最新)**
|
|
45
|
+
- 单独上传或恢复 **Pi 备份**
|
|
46
|
+
- 单独上传或恢复 **Skills 备份**
|
|
47
|
+
- 单独上传或恢复 **会话归档**
|
|
48
|
+
- 配置备份设置
|
|
49
|
+
- 中英文菜单切换
|
|
59
50
|
|
|
60
|
-
|
|
51
|
+
首次运行若缺少 WebDAV 地址、用户名或密码,会自动打开配置向导。
|
|
61
52
|
|
|
62
|
-
|
|
53
|
+
## 归档模型
|
|
63
54
|
|
|
64
|
-
|
|
65
|
-
# 1. 安装
|
|
66
|
-
pi install git:github.com/wuyaos/pi-packages
|
|
55
|
+
### Pi 备份
|
|
67
56
|
|
|
68
|
-
|
|
69
|
-
/sync
|
|
57
|
+
Pi 归档直接打包 `~/.pi/agent`。默认黑名单:
|
|
70
58
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
75
|
+
```text
|
|
76
|
+
backup/skills/agent_skills_<timestamp>.tar.xz
|
|
77
|
+
```
|
|
89
78
|
|
|
90
|
-
###
|
|
79
|
+
### 会话归档
|
|
91
80
|
|
|
92
|
-
|
|
81
|
+
会话按项目归档,不再进行实时同步,也没有 `_latest.json` marker:
|
|
93
82
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
89
|
+
- **白名单**:仅归档列表中的项目;空列表表示全部不归档。
|
|
90
|
+
- **黑名单**:排除列表中的项目;空列表表示归档全部项目。
|
|
102
91
|
|
|
103
|
-
|
|
92
|
+
开启 `backupOnExit` 后,Pi 在 `session_shutdown` 时归档当前允许的项目;仍可通过 `/sync` 手动归档。
|
|
104
93
|
|
|
105
|
-
|
|
94
|
+
## WebDAV 目录结构
|
|
106
95
|
|
|
107
96
|
```text
|
|
108
|
-
|
|
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
|
-
|
|
131
|
+
密码支持环境变量引用(如 `$PI_WEBDAV_PASS`)。未设置 `language` 时,会读取 `settings.json → piSwitch.language`,无法识别则默认英文。
|
|
112
132
|
|
|
113
|
-
|
|
133
|
+
## 恢复行为与安全
|
|
114
134
|
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
135
|
+
- 检查归档中的绝对路径和目录穿越。
|
|
136
|
+
- 解压前拒绝符号链接和非普通文件节点。
|
|
137
|
+
- 先解压到临时目录,再合并到目标目录。
|
|
138
|
+
- 若目标路径包含符号链接或类型冲突则拒绝恢复。
|
|
139
|
+
- Pi 恢复只改写归档中存在的文件,黑名单目录保持原样。
|
|
140
|
+
- Skills 恢复前会把现有 `~/.agents/skills` 移到带时间戳的备份目录。
|
|
141
|
+
- 会话恢复以合并方式写入 `~/.pi/agent/sessions`。
|
|
142
|
+
- Pi 恢复前展示计划,完成后可选择 `/reload`。
|
|
119
143
|
|
|
120
|
-
|
|
144
|
+
归档可能包含 WebDAV 凭据、API Key、provider 配置及其他秘密,应把 WebDAV 存储视为敏感数据。
|
|
121
145
|
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
$env:
|
|
127
|
-
$env:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
```
|
|
166
|
-
pi-
|
|
167
|
-
|
|
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
|
|
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
|
-
-
|
|
190
|
-
- 从 bootstrap 脚本示例中移除真实凭证
|
|
191
|
-
- 增加 MIT `LICENSE`,扩充 README(安全、恢复保护、故障排查、菜单截图)
|
|
206
|
+
- 缓存 WebDAV 目录创建结果与已加载配置,减少重复 I/O
|
|
192
207
|
|
|
193
208
|
### v1.0.0
|
|
194
209
|
|
|
195
|
-
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
}
|