@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,91 @@
|
|
|
1
|
+
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fetchWithTimeout } from "../_shared/fetch-utils";
|
|
5
|
+
import { resolvePassword, type SyncConfig } from "./config";
|
|
6
|
+
|
|
7
|
+
export const WEBDAV_FETCH_TIMEOUT_MS = 120_000;
|
|
8
|
+
export const WEBDAV_CONFIG_DIR = "config/";
|
|
9
|
+
export const WEBDAV_MEMORY_DIR = "memory/";
|
|
10
|
+
export const WEBDAV_AGENT_SKILLS_DIR = "agent-skills/";
|
|
11
|
+
export const WEBDAV_SESSIONS_DIR = "sessions/";
|
|
12
|
+
|
|
13
|
+
export const ensureTrailingSlash = (url: string): string => url.endsWith("/") ? url : `${url}/`;
|
|
14
|
+
export const webdavDirBase = (config: SyncConfig, remoteDir: string): string => ensureTrailingSlash(config.webdavUrl) + remoteDir.replace(/^\/+/, "");
|
|
15
|
+
export const configWebdavBase = (config: SyncConfig): string => webdavDirBase(config, WEBDAV_CONFIG_DIR);
|
|
16
|
+
export const memoryWebdavBase = (config: SyncConfig): string => webdavDirBase(config, WEBDAV_MEMORY_DIR);
|
|
17
|
+
export const agentSkillsWebdavBase = (config: SyncConfig): string => webdavDirBase(config, WEBDAV_AGENT_SKILLS_DIR);
|
|
18
|
+
export const sessionsWebdavBase = (config: SyncConfig): string => webdavDirBase(config, WEBDAV_SESSIONS_DIR);
|
|
19
|
+
export const webdavAuth = (config: SyncConfig): string => "Basic " + Buffer.from(`${config.webdavUser}:${resolvePassword(config.webdavPass)}`).toString("base64");
|
|
20
|
+
|
|
21
|
+
export async function webdavList(url: string, auth: string, ctx: ExtensionContext, filter?: (name: string) => boolean): Promise<string[]> {
|
|
22
|
+
const response = await fetchWithTimeout(url, { method: "PROPFIND", headers: { Authorization: auth, Depth: "1", "Content-Type": "application/xml" } }, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
|
|
23
|
+
if (!response.ok) throw new Error(`WebDAV PROPFIND HTTP ${response.status}: ${response.statusText}`);
|
|
24
|
+
const text = await response.text();
|
|
25
|
+
const names = new Set<string>();
|
|
26
|
+
let match: RegExpExecArray | null;
|
|
27
|
+
const display = /<[a-zA-Z0-9:-]*displayname>([^<]+)<\/[a-zA-Z0-9:-]*displayname>/g;
|
|
28
|
+
while ((match = display.exec(text))) if (match[1]?.trim()) names.add(match[1].trim());
|
|
29
|
+
if (names.size === 0) {
|
|
30
|
+
const href = /<[a-zA-Z0-9:-]*href>([^<]+)<\/[a-zA-Z0-9:-]*href>/g;
|
|
31
|
+
while ((match = href.exec(text))) {
|
|
32
|
+
const name = path.basename(decodeURIComponent(match[1]!.trim()));
|
|
33
|
+
if (name) names.add(name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
let result = [...names];
|
|
37
|
+
if (filter) result = result.filter(filter);
|
|
38
|
+
return result.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function webdavPutFile(localPath: string, remoteUrl: string, auth: string, ctx: ExtensionContext): Promise<void> {
|
|
42
|
+
const response = await fetchWithTimeout(remoteUrl, { method: "PUT", headers: { Authorization: auth, "Content-Type": "application/octet-stream" }, body: fs.readFileSync(localPath) }, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
|
|
43
|
+
if (!response.ok) throw new Error(`WebDAV PUT HTTP ${response.status}: ${response.statusText}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function webdavGetFile(remoteUrl: string, destPath: string, auth: string, ctx: ExtensionContext): Promise<void> {
|
|
47
|
+
const response = await fetchWithTimeout(remoteUrl, { method: "GET", headers: { Authorization: auth } }, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
|
|
48
|
+
if (!response.ok) throw new Error(`WebDAV GET HTTP ${response.status}: ${response.statusText}`);
|
|
49
|
+
fs.writeFileSync(destPath, Buffer.from(await response.arrayBuffer()));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function webdavMkcol(url: string, auth: string, ctx: ExtensionContext): Promise<void> {
|
|
53
|
+
const response = await fetchWithTimeout(url, { method: "MKCOL", headers: { Authorization: auth } }, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
|
|
54
|
+
if (!response.ok && response.status !== 405) throw new Error(`WebDAV MKCOL HTTP ${response.status}: ${response.statusText}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function listWebdavDir(remoteDir: string, config: SyncConfig, ctx: ExtensionContext): Promise<string[]> {
|
|
58
|
+
try { return await webdavList(webdavDirBase(config, remoteDir), webdavAuth(config), ctx); }
|
|
59
|
+
catch (error) { if (error instanceof Error && /HTTP 404/.test(error.message)) return []; throw error; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function ensureWebdavDirectory(remoteDir: string, config: SyncConfig, ctx: ExtensionContext): Promise<string> {
|
|
63
|
+
let current = ensureTrailingSlash(config.webdavUrl);
|
|
64
|
+
for (const segment of remoteDir.split("/").filter(Boolean)) {
|
|
65
|
+
current += `${encodeURIComponent(segment)}/`;
|
|
66
|
+
await webdavMkcol(current, webdavAuth(config), ctx);
|
|
67
|
+
}
|
|
68
|
+
return current;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function uploadToWebdavDir(localPath: string, remoteDir: string, remoteName: string, config: SyncConfig, ctx: ExtensionContext): Promise<void> {
|
|
72
|
+
const base = await ensureWebdavDirectory(remoteDir, config, ctx);
|
|
73
|
+
await webdavPutFile(localPath, base + encodeURIComponent(remoteName), webdavAuth(config), ctx);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function downloadFromWebdavDir(remoteName: string, remoteDir: string, destPath: string, config: SyncConfig, ctx: ExtensionContext): Promise<void> {
|
|
77
|
+
await webdavGetFile(webdavDirBase(config, remoteDir) + encodeURIComponent(remoteName), destPath, webdavAuth(config), ctx);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function deleteFromWebdavDir(remoteName: string, remoteDir: string, config: SyncConfig, ctx: ExtensionContext): Promise<void> {
|
|
81
|
+
const response = await fetchWithTimeout(webdavDirBase(config, remoteDir) + encodeURIComponent(remoteName), { method: "DELETE", headers: { Authorization: webdavAuth(config) } }, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
|
|
82
|
+
if (!response.ok && response.status !== 404) throw new Error(`WebDAV DELETE HTTP ${response.status}: ${response.statusText}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function pruneOldBackupsInDir(config: SyncConfig, ctx: ExtensionContext, remoteDir: string, prefix: string): Promise<string[]> {
|
|
86
|
+
if (config.maxBackups <= 0) return [];
|
|
87
|
+
const files = (await listWebdavDir(remoteDir, config, ctx)).filter((name) => name.startsWith(prefix) && name.endsWith(".tar.xz")).sort().reverse();
|
|
88
|
+
const deleted = files.slice(config.maxBackups);
|
|
89
|
+
for (const name of deleted) try { await deleteFromWebdavDir(name, remoteDir, config, ctx); } catch { /* best effort */ }
|
|
90
|
+
return deleted;
|
|
91
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wuyaos/pi-sync",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "WebDAV config sync for Pi coding agent — backup/restore models, settings, skills, extensions, and selected session projects",
|
|
5
|
+
"keywords": ["pi-package", "webdav", "sync", "backup", "coding-agent"],
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/wuyaos/pi-packages.git",
|
|
9
|
+
"directory": "pi-sync"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"extensions",
|
|
16
|
+
"docs",
|
|
17
|
+
"pi-bootstrap.ps1",
|
|
18
|
+
"README.md",
|
|
19
|
+
"README.zh-CN.md",
|
|
20
|
+
"PROMO.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/wuyaos/pi-packages/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/wuyaos/pi-packages#readme",
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": ["./extensions/sync"],
|
|
29
|
+
"image": "https://img.shields.io/badge/pi-sync-WebDAV-blue"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
package/pi-bootstrap.ps1
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# pi-bootstrap.ps1
|
|
2
|
+
# Pull the latest Pi config backup from WebDAV onto a new machine.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# .\pi-bootstrap.ps1 -WebdavUrl "https://your-webdav.example/dav/Pi" -User "your-user" -Pass "your-app-password"
|
|
6
|
+
#
|
|
7
|
+
# Or set env vars (recommended):
|
|
8
|
+
# $env:PI_WEBDAV_URL = "https://your-webdav.example/dav/Pi"
|
|
9
|
+
# $env:PI_WEBDAV_USER = "your-user"
|
|
10
|
+
# $env:PI_WEBDAV_PASS = "your-app-password"
|
|
11
|
+
# .\pi-bootstrap.ps1
|
|
12
|
+
#
|
|
13
|
+
# Security: never commit real credentials. Prefer app-specific passwords
|
|
14
|
+
# and store them only in env vars / your password manager.
|
|
15
|
+
|
|
16
|
+
param(
|
|
17
|
+
[string]$WebdavUrl = $env:PI_WEBDAV_URL,
|
|
18
|
+
[string]$User = $env:PI_WEBDAV_USER,
|
|
19
|
+
[string]$Pass = $env:PI_WEBDAV_PASS
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
$ErrorActionPreference = "Stop"
|
|
23
|
+
|
|
24
|
+
if (-not $WebdavUrl -or -not $User -or -not $Pass) {
|
|
25
|
+
Write-Host "Usage: .\pi-bootstrap.ps1 -WebdavUrl <url> -User <user> -Pass <pass>" -ForegroundColor Red
|
|
26
|
+
Write-Host "Or set PI_WEBDAV_URL, PI_WEBDAV_USER, PI_WEBDAV_PASS env vars" -ForegroundColor Yellow
|
|
27
|
+
exit 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$WebdavUrl = $WebdavUrl.TrimEnd('/')
|
|
31
|
+
$pair = "${User}:${Pass}"
|
|
32
|
+
$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair))
|
|
33
|
+
$headers = @{ Authorization = "Basic $auth"; Depth = "1" }
|
|
34
|
+
|
|
35
|
+
Write-Host "[1/5] Listing backups on WebDAV..." -ForegroundColor Cyan
|
|
36
|
+
$resp = Invoke-RestMethod -Uri $WebdavUrl -Method PROPFIND -Headers $headers -ContentType "application/xml"
|
|
37
|
+
|
|
38
|
+
# Parse XML for filenames
|
|
39
|
+
$files = ([regex]'<d:href>([^<]+)</d:href>').Matches($resp) |
|
|
40
|
+
ForEach-Object { $_.Groups[1].Value } |
|
|
41
|
+
Where-Object { $_ -match 'pi_sync_backup_.*\.zip$' } |
|
|
42
|
+
Sort-Object -Descending
|
|
43
|
+
|
|
44
|
+
if ($files.Count -eq 0) {
|
|
45
|
+
Write-Host "No backups found on WebDAV!" -ForegroundColor Red
|
|
46
|
+
exit 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$latest = $files[0]
|
|
50
|
+
$name = [System.IO.Path]::GetFileName($latest)
|
|
51
|
+
Write-Host "[2/5] Latest backup: $name" -ForegroundColor Green
|
|
52
|
+
|
|
53
|
+
$tempZip = "$env:TEMP\$name"
|
|
54
|
+
Write-Host "[3/5] Downloading..." -ForegroundColor Cyan
|
|
55
|
+
Invoke-WebRequest -Uri "$WebdavUrl/$name" -Headers @{ Authorization = "Basic $auth" } -OutFile $tempZip
|
|
56
|
+
|
|
57
|
+
$tempDir = "$env:TEMP\pi_restore_$(Get-Date -Format 'yyyyMMddHHmmss')"
|
|
58
|
+
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
|
59
|
+
|
|
60
|
+
Write-Host "[4/5] Extracting..." -ForegroundColor Cyan
|
|
61
|
+
tar -xf $tempZip -C $tempDir
|
|
62
|
+
|
|
63
|
+
$agentDir = "$env:USERPROFILE\.pi\agent"
|
|
64
|
+
$backupSuffix = "bak-$(Get-Date -Format 'yyyyMMddHHmmss')"
|
|
65
|
+
|
|
66
|
+
# Restore config
|
|
67
|
+
if (Test-Path "$tempDir\config") {
|
|
68
|
+
Write-Host " → Restoring config files..." -ForegroundColor Yellow
|
|
69
|
+
Get-ChildItem "$tempDir\config" | ForEach-Object {
|
|
70
|
+
$dest = Join-Path $agentDir $_.Name
|
|
71
|
+
if (Test-Path $dest) {
|
|
72
|
+
Copy-Item $dest "$dest.$backupSuffix"
|
|
73
|
+
Write-Host " Backup: $($_.Name) → $($_.Name).$backupSuffix"
|
|
74
|
+
}
|
|
75
|
+
Copy-Item $_.FullName $dest -Force
|
|
76
|
+
Write-Host " Restored: $($_.Name)" -ForegroundColor Green
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Restore skills
|
|
81
|
+
if (Test-Path "$tempDir\skills") {
|
|
82
|
+
Write-Host " → Restoring skills..." -ForegroundColor Yellow
|
|
83
|
+
$skillsDest = "$agentDir\skills"
|
|
84
|
+
if (Test-Path $skillsDest) {
|
|
85
|
+
Rename-Item $skillsDest "skills-$backupSuffix"
|
|
86
|
+
Write-Host " Backup: skills → skills-$backupSuffix"
|
|
87
|
+
}
|
|
88
|
+
Copy-Item "$tempDir\skills" $skillsDest -Recurse
|
|
89
|
+
Write-Host " Skills restored" -ForegroundColor Green
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Restore extensions
|
|
93
|
+
if (Test-Path "$tempDir\extensions") {
|
|
94
|
+
Write-Host " → Restoring extensions..." -ForegroundColor Yellow
|
|
95
|
+
$extDest = "$agentDir\extensions"
|
|
96
|
+
if (Test-Path $extDest) {
|
|
97
|
+
Rename-Item $extDest "extensions-$backupSuffix"
|
|
98
|
+
Write-Host " Backup: extensions → extensions-$backupSuffix"
|
|
99
|
+
}
|
|
100
|
+
Copy-Item "$tempDir\extensions" $extDest -Recurse
|
|
101
|
+
Write-Host " Extensions restored" -ForegroundColor Green
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Cleanup
|
|
105
|
+
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
|
|
106
|
+
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
107
|
+
|
|
108
|
+
Write-Host "[5/5] Done! Pi config restored to $agentDir" -ForegroundColor Green
|
|
109
|
+
Write-Host ""
|
|
110
|
+
Write-Host "Next steps:" -ForegroundColor Cyan
|
|
111
|
+
Write-Host " 1. Restart Pi (or /reload)"
|
|
112
|
+
Write-Host " 2. Run: pi update --extensions (to install packages from settings.json)"
|
|
113
|
+
Write-Host " 3. /sync pull (to pull future updates)"
|