@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.
@@ -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
+ }
@@ -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)"