@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.
@@ -2,100 +2,136 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { timestampForBackup } from "../_shared/json-io";
5
- import { copyRecursiveSync, listArchiveEntries, runTar, validateArchiveEntries } from "./archive";
6
- import {
7
- AGENT_DIR,
8
- AGENT_ROOT_MARKDOWN_FILES,
9
- AGENT_SKILLS_DIR,
10
- MEMORY_MARKDOWN_FILES,
11
- SESSIONS_DIR,
12
- ensureDir,
13
- type SyncConfig,
14
- } from "./config";
5
+ import { copyRecursiveSync, listArchiveEntries, runTar, validateArchiveEntries, validateArchiveEntryTypes } from "./archive";
6
+ import { AGENT_DIR, AGENT_SKILLS_DIR, SESSIONS_DIR, ensureDir } from "./config";
15
7
 
16
8
  async function extractToTemp(archivePath: string, prefix: string): Promise<string> {
17
9
  const tempDir = path.join(os.tmpdir(), `${prefix}_${Date.now()}`);
18
10
  ensureDir(tempDir);
19
11
  validateArchiveEntries(await listArchiveEntries(archivePath));
20
- await runTar(["-x", "-f", archivePath, "-C", tempDir]);
12
+ await validateArchiveEntryTypes(archivePath);
13
+ await runTar(["-x", "--no-same-owner", "--no-same-permissions", "-f", archivePath, "-C", tempDir]);
21
14
  return tempDir;
22
15
  }
23
16
 
24
- function backupAndCopyFile(src: string, dest: string): void {
25
- ensureDir(path.dirname(dest));
26
- if (fs.existsSync(dest) && fs.statSync(dest).isFile()) fs.copyFileSync(dest, `${dest}.bak-${timestampForBackup()}`);
27
- fs.copyFileSync(src, dest);
28
- }
29
-
30
- export function getRestorePlan(entries: string[], config: SyncConfig): string[] {
31
- const has = (name: string) => entries.some((entry) => entry === name || entry.startsWith(`${name}/`));
32
- const plan: string[] = [];
33
- if (has("config")) plan.push(config.backupProviders ? "Config files will be overwritten after timestamped .bak copies are created." : "Config files are present but skipped by current settings.");
34
- if (has("skills")) plan.push(config.backupSkills ? "Pi skills will be replaced after a timestamped directory backup." : "Pi skills are present but skipped by current settings.");
35
- if (has("extensions")) plan.push(config.backupExtensions ? "Extensions will be merged after a timestamped directory backup." : "Extensions are present but skipped by current settings.");
36
- if (has("sessions")) plan.push(config.backupSessions ? "Sessions will be merged into ~/.pi/agent/sessions/." : "Sessions are present but skipped by current settings.");
37
- if (has("memory")) plan.push(config.backupMemory ? "Durable memory markdown files will be restored after .bak copies." : "Memory files are present but skipped by current settings.");
38
- if (has("agent-skills")) plan.push(config.backupAgentSkills ? "~/.agents/skills will be replaced after moving the current directory to a backup." : "Shared skills are present but skipped by current settings.");
39
- return plan.length ? plan : ["No restorable content was found in this archive."];
17
+ function assertSafeRestoreParent(root: string, destination: string): void {
18
+ const relative = path.relative(root, path.dirname(destination));
19
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Restore destination escapes root: ${destination}`);
20
+ let current = root;
21
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
22
+ current = path.join(current, segment);
23
+ if (!fs.existsSync(current)) continue;
24
+ const stats = fs.lstatSync(current);
25
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
26
+ throw new Error(`Unsafe restore parent rejected: ${current}`);
27
+ }
28
+ }
40
29
  }
41
30
 
42
- export async function extractConfigZip(archivePath: string, config: SyncConfig): Promise<string[]> {
43
- const tempDir = await extractToTemp(archivePath, "pi_config_extract");
44
- const restored: string[] = [];
45
- try {
46
- const root = path.join(tempDir, "config", "root");
47
- if (config.backupProviders && fs.existsSync(root)) {
48
- const allowedMd = new Set<string>(AGENT_ROOT_MARKDOWN_FILES);
49
- for (const name of fs.readdirSync(root)) {
50
- const src = path.join(root, name);
51
- if (!fs.statSync(src).isFile() || (!name.endsWith(".json") && !allowedMd.has(name))) continue;
52
- backupAndCopyFile(src, path.join(AGENT_DIR, name));
53
- restored.push(`Config: ${name}`);
31
+ function copyExtractedTree(source: string, destination: string, root: string): number {
32
+ const sourceStats = fs.lstatSync(source);
33
+ if (sourceStats.isSymbolicLink() || (!sourceStats.isFile() && !sourceStats.isDirectory())) {
34
+ throw new Error(`Unsafe extracted entry rejected: ${source}`);
35
+ }
36
+ assertSafeRestoreParent(root, destination);
37
+ if (sourceStats.isDirectory()) {
38
+ if (fs.existsSync(destination)) {
39
+ const destinationStats = fs.lstatSync(destination);
40
+ if (destinationStats.isSymbolicLink() || !destinationStats.isDirectory()) {
41
+ throw new Error(`Unsafe restore destination rejected: ${destination}`);
54
42
  }
43
+ } else {
44
+ fs.mkdirSync(destination, { recursive: true });
55
45
  }
56
- const sub = path.join(tempDir, "config", "sub");
57
- if (config.backupProviders && fs.existsSync(sub)) {
58
- const dest = path.join(AGENT_DIR, "config");
59
- if (fs.existsSync(dest)) copyRecursiveSync(dest, path.join(AGENT_DIR, `config-backup-${timestampForBackup()}`));
60
- copyRecursiveSync(sub, dest);
61
- restored.push("Config directory");
46
+ let copied = 0;
47
+ for (const child of fs.readdirSync(source)) {
48
+ copied += copyExtractedTree(path.join(source, child), path.join(destination, child), root);
62
49
  }
63
- const extensions = path.join(tempDir, "extensions");
64
- if (config.backupExtensions && fs.existsSync(extensions)) {
65
- const dest = path.join(AGENT_DIR, "extensions");
66
- if (fs.existsSync(dest)) copyRecursiveSync(dest, path.join(AGENT_DIR, `extensions-backup-${timestampForBackup()}`));
67
- copyRecursiveSync(extensions, dest);
68
- restored.push("Extensions");
50
+ return copied;
51
+ }
52
+ if (fs.existsSync(destination)) {
53
+ const destinationStats = fs.lstatSync(destination);
54
+ if (destinationStats.isSymbolicLink() || !destinationStats.isFile()) {
55
+ throw new Error(`Unsafe restore destination rejected: ${destination}`);
69
56
  }
70
- return restored;
71
- } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
57
+ }
58
+ ensureDir(path.dirname(destination));
59
+ fs.copyFileSync(source, destination);
60
+ return 1;
72
61
  }
73
62
 
74
- export async function extractMemoryZip(archivePath: string): Promise<string[]> {
75
- const tempDir = await extractToTemp(archivePath, "pi_memory_extract");
76
- const destDir = path.join(AGENT_DIR, "pi-hermes-memory");
77
- const restored: string[] = [];
63
+ /** Merge an archive created by createPiAgentZip into ~/.pi/agent. */
64
+ export async function extractPiAgentZip(archivePath: string, targetDir = AGENT_DIR): Promise<string[]> {
65
+ const tempDir = await extractToTemp(archivePath, "pi_agent_extract");
66
+ const target = path.resolve(targetDir);
78
67
  try {
79
- for (const name of MEMORY_MARKDOWN_FILES) {
80
- const src = path.join(tempDir, "memory", name);
81
- if (!fs.existsSync(src)) continue;
82
- backupAndCopyFile(src, path.join(destDir, name));
83
- restored.push(name);
68
+ fs.mkdirSync(target, { recursive: true });
69
+ const targetStats = fs.lstatSync(target);
70
+ if (targetStats.isSymbolicLink() || !targetStats.isDirectory()) {
71
+ throw new Error(`Pi agent restore target is not a safe directory: ${target}`);
84
72
  }
85
- return restored;
73
+ let fileCount = 0;
74
+ for (const name of fs.readdirSync(tempDir)) {
75
+ fileCount += copyExtractedTree(path.join(tempDir, name), path.join(target, name), target);
76
+ }
77
+ if (fileCount === 0) throw new Error("Pi backup archive contained no files.");
78
+ return [`Pi agent: ${fileCount} file(s) merged`];
86
79
  } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
87
80
  }
88
81
 
89
- export async function extractAgentSkillsZip(archivePath: string): Promise<string[]> {
82
+ export function getRestorePlan(entries: string[]): string[] {
83
+ if (entries.some((entry) => entry === "settings.json" || entry === "models.json" || entry === "AGENTS.md")) {
84
+ return ["Pi agent files will be merged; excluded install/session/state directories remain untouched."];
85
+ }
86
+ if (entries.some((entry) => entry === "agent-skills" || entry.startsWith("agent-skills/"))) {
87
+ return ["Shared skills will replace ~/.agents/skills after the current directory is moved to a timestamped backup."];
88
+ }
89
+ if (entries.some((entry) => entry === "sessions" || entry.startsWith("sessions/"))) {
90
+ return ["Session files will be merged into ~/.pi/agent/sessions/."];
91
+ }
92
+ return ["Archive files will be merged into their destination."];
93
+ }
94
+
95
+ export async function extractAgentSkillsZip(archivePath: string, targetDir = AGENT_SKILLS_DIR): Promise<string[]> {
90
96
  const tempDir = await extractToTemp(archivePath, "pi_agent_skills_extract");
97
+ const target = path.resolve(targetDir);
98
+ const parent = path.dirname(target);
99
+ const name = path.basename(target);
100
+ const staging = path.join(parent, `.${name}-restore-${process.pid}-${Date.now()}`);
101
+ const backup = path.join(parent, `${name}-backup-${timestampForBackup()}`);
102
+ let previousMoved = false;
91
103
  try {
92
104
  const source = path.join(tempDir, "agent-skills");
93
105
  if (!fs.existsSync(source)) throw new Error("Archive does not contain agent-skills/.");
94
- const backup = path.join(os.homedir(), ".agents", `skills-backup-${timestampForBackup()}`);
95
- if (fs.existsSync(AGENT_SKILLS_DIR)) fs.renameSync(AGENT_SKILLS_DIR, backup);
96
- copyRecursiveSync(source, AGENT_SKILLS_DIR);
97
- return [`Shared skills; previous directory moved to ${backup}`];
98
- } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
106
+ ensureDir(parent);
107
+ // Complete the potentially fallible recursive copy before touching the
108
+ // current skills directory. Staging shares the target filesystem so the
109
+ // final rename is atomic.
110
+ copyRecursiveSync(source, staging);
111
+ if (fs.existsSync(target)) {
112
+ if (fs.existsSync(backup)) throw new Error(`Skills backup destination already exists: ${backup}`);
113
+ fs.renameSync(target, backup);
114
+ previousMoved = true;
115
+ }
116
+ try {
117
+ fs.renameSync(staging, target);
118
+ } catch (error) {
119
+ if (previousMoved && !fs.existsSync(target) && fs.existsSync(backup)) {
120
+ try {
121
+ fs.renameSync(backup, target);
122
+ } catch (rollbackError) {
123
+ console.error(`[pi-sync] Failed to roll back shared skills restore: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
124
+ }
125
+ }
126
+ throw error;
127
+ }
128
+ return previousMoved
129
+ ? [`Shared skills restored; previous directory moved to ${backup}`]
130
+ : ["Shared skills restored"];
131
+ } finally {
132
+ fs.rmSync(staging, { recursive: true, force: true });
133
+ fs.rmSync(tempDir, { recursive: true, force: true });
134
+ }
99
135
  }
100
136
 
101
137
  export async function extractSessionsArchiveZip(archivePath: string): Promise<string[]> {
@@ -103,38 +139,8 @@ export async function extractSessionsArchiveZip(archivePath: string): Promise<st
103
139
  try {
104
140
  const source = path.join(tempDir, "sessions");
105
141
  if (!fs.existsSync(source)) throw new Error("Archive does not contain sessions/.");
106
- copyRecursiveSync(source, SESSIONS_DIR);
107
- return ["Session archive merged"];
108
- } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
109
- }
110
-
111
- export async function extractLegacyZip(archivePath: string, config: SyncConfig): Promise<string[]> {
112
- const tempDir = await extractToTemp(archivePath, "pi_sync_extract");
113
- const restored: string[] = [];
114
- try {
115
- const configDir = path.join(tempDir, "config");
116
- if (config.backupProviders && fs.existsSync(configDir)) {
117
- for (const name of fs.readdirSync(configDir)) {
118
- const src = path.join(configDir, name);
119
- if (fs.statSync(src).isFile()) { backupAndCopyFile(src, path.join(AGENT_DIR, name)); restored.push(`Config: ${name}`); }
120
- }
121
- }
122
- const skills = path.join(tempDir, "skills");
123
- if (config.backupSkills && fs.existsSync(skills)) {
124
- const dest = path.join(AGENT_DIR, "skills"), backup = path.join(AGENT_DIR, `skills-backup-${timestampForBackup()}`);
125
- if (fs.existsSync(dest)) fs.renameSync(dest, backup);
126
- copyRecursiveSync(skills, dest);
127
- restored.push("Skills");
128
- }
129
- const extensions = path.join(tempDir, "extensions");
130
- if (config.backupExtensions && fs.existsSync(extensions)) {
131
- const dest = path.join(AGENT_DIR, "extensions");
132
- if (fs.existsSync(dest)) copyRecursiveSync(dest, path.join(AGENT_DIR, `extensions-backup-${timestampForBackup()}`));
133
- copyRecursiveSync(extensions, dest);
134
- restored.push("Extensions");
135
- }
136
- const sessions = path.join(tempDir, "sessions");
137
- if (config.backupSessions && fs.existsSync(sessions)) { copyRecursiveSync(sessions, SESSIONS_DIR); restored.push("Sessions"); }
138
- return restored;
142
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
143
+ const fileCount = copyExtractedTree(source, SESSIONS_DIR, SESSIONS_DIR);
144
+ return [`Session archive merged: ${fileCount} file(s)`];
139
145
  } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
140
146
  }
@@ -1,21 +1,18 @@
1
1
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
4
6
  import { fetchWithTimeout } from "../_shared/fetch-utils";
5
7
  import { resolvePassword, type SyncConfig } from "./config";
6
8
 
7
9
  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/";
10
+ export const WEBDAV_PI_BACKUP_DIR = "backup/pi/";
11
+ export const WEBDAV_AGENT_SKILLS_DIR = "backup/skills/";
12
+ export const WEBDAV_SESSIONS_ARCHIVE_DIR = "backup/sessions/";
12
13
 
13
14
  export const ensureTrailingSlash = (url: string): string => url.endsWith("/") ? url : `${url}/`;
14
15
  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
16
  export const webdavAuth = (config: SyncConfig): string => "Basic " + Buffer.from(`${config.webdavUser}:${resolvePassword(config.webdavPass)}`).toString("base64");
20
17
 
21
18
  export async function webdavList(url: string, auth: string, ctx: ExtensionContext, filter?: (name: string) => boolean): Promise<string[]> {
@@ -39,14 +36,32 @@ export async function webdavList(url: string, auth: string, ctx: ExtensionContex
39
36
  }
40
37
 
41
38
  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}`);
39
+ const stream = fs.createReadStream(localPath);
40
+ try {
41
+ const response = await fetchWithTimeout(remoteUrl, {
42
+ method: "PUT",
43
+ headers: { Authorization: auth, "Content-Type": "application/octet-stream" },
44
+ body: stream as unknown as BodyInit,
45
+ // Node fetch requires this for a streaming request body.
46
+ duplex: "half",
47
+ } as RequestInit, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
48
+ if (!response.ok) throw new Error(`WebDAV PUT HTTP ${response.status}: ${response.statusText}`);
49
+ } finally {
50
+ stream.destroy();
51
+ }
44
52
  }
45
53
 
46
54
  export async function webdavGetFile(remoteUrl: string, destPath: string, auth: string, ctx: ExtensionContext): Promise<void> {
47
55
  const response = await fetchWithTimeout(remoteUrl, { method: "GET", headers: { Authorization: auth } }, WEBDAV_FETCH_TIMEOUT_MS, ctx.signal);
48
56
  if (!response.ok) throw new Error(`WebDAV GET HTTP ${response.status}: ${response.statusText}`);
49
- fs.writeFileSync(destPath, Buffer.from(await response.arrayBuffer()));
57
+ if (!response.body) throw new Error("WebDAV GET returned an empty response body");
58
+ const tempPath = `${destPath}.part-${process.pid}-${Date.now()}`;
59
+ try {
60
+ await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(tempPath), { signal: ctx.signal });
61
+ fs.renameSync(tempPath, destPath);
62
+ } finally {
63
+ fs.rmSync(tempPath, { force: true });
64
+ }
50
65
  }
51
66
 
52
67
  export async function webdavMkcol(url: string, auth: string, ctx: ExtensionContext): Promise<void> {
@@ -59,11 +74,17 @@ export async function listWebdavDir(remoteDir: string, config: SyncConfig, ctx:
59
74
  catch (error) { if (error instanceof Error && /HTTP 404/.test(error.message)) return []; throw error; }
60
75
  }
61
76
 
77
+ /** Process-level cache of already-ensured WebDAV directory URLs, avoids repeated MKCOL storms. */
78
+ const ensuredDirs = new Set<string>();
79
+
62
80
  export async function ensureWebdavDirectory(remoteDir: string, config: SyncConfig, ctx: ExtensionContext): Promise<string> {
63
81
  let current = ensureTrailingSlash(config.webdavUrl);
64
82
  for (const segment of remoteDir.split("/").filter(Boolean)) {
65
83
  current += `${encodeURIComponent(segment)}/`;
66
- await webdavMkcol(current, webdavAuth(config), ctx);
84
+ if (!ensuredDirs.has(current)) {
85
+ await webdavMkcol(current, webdavAuth(config), ctx);
86
+ ensuredDirs.add(current);
87
+ }
67
88
  }
68
89
  return current;
69
90
  }
package/package.json CHANGED
@@ -1,8 +1,14 @@
1
1
  {
2
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"],
3
+ "version": "1.2.0",
4
+ "description": "WebDAV archive backup and restore for Pi agent data, shared skills, and per-project sessions",
5
+ "keywords": [
6
+ "pi-package",
7
+ "webdav",
8
+ "sync",
9
+ "backup",
10
+ "coding-agent"
11
+ ],
6
12
  "repository": {
7
13
  "type": "git",
8
14
  "url": "https://github.com/wuyaos/pi-packages.git",
@@ -13,7 +19,7 @@
13
19
  },
14
20
  "files": [
15
21
  "extensions",
16
- "docs",
22
+ "!extensions/**/*.test.ts",
17
23
  "pi-bootstrap.ps1",
18
24
  "README.md",
19
25
  "README.zh-CN.md",
@@ -25,7 +31,9 @@
25
31
  },
26
32
  "homepage": "https://github.com/wuyaos/pi-packages#readme",
27
33
  "pi": {
28
- "extensions": ["./extensions/sync"],
34
+ "extensions": [
35
+ "./extensions/sync"
36
+ ],
29
37
  "image": "https://img.shields.io/badge/pi-sync-WebDAV-blue"
30
38
  },
31
39
  "peerDependencies": {
package/pi-bootstrap.ps1 CHANGED
@@ -1,17 +1,14 @@
1
1
  # pi-bootstrap.ps1
2
- # Pull the latest Pi config backup from WebDAV onto a new machine.
2
+ # Restore the latest trusted Pi agent archive from WebDAV on a new Windows machine.
3
3
  #
4
- # Usage:
5
- # .\pi-bootstrap.ps1 -WebdavUrl "https://your-webdav.example/dav/Pi" -User "your-user" -Pass "your-app-password"
4
+ # Security: this bootstrap helper does not run pi-sync's TypeScript archive path/link
5
+ # validation. Use it only with a trusted WebDAV endpoint and archives you trust.
6
6
  #
7
- # Or set env vars (recommended):
8
- # $env:PI_WEBDAV_URL = "https://your-webdav.example/dav/Pi"
7
+ # Recommended usage:
8
+ # $env:PI_WEBDAV_URL = "https://your-webdav.example/dav/pi"
9
9
  # $env:PI_WEBDAV_USER = "your-user"
10
10
  # $env:PI_WEBDAV_PASS = "your-app-password"
11
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
12
 
16
13
  param(
17
14
  [string]$WebdavUrl = $env:PI_WEBDAV_URL,
@@ -23,91 +20,53 @@ $ErrorActionPreference = "Stop"
23
20
 
24
21
  if (-not $WebdavUrl -or -not $User -or -not $Pass) {
25
22
  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
23
+ Write-Host "Or set PI_WEBDAV_URL, PI_WEBDAV_USER, PI_WEBDAV_PASS." -ForegroundColor Yellow
27
24
  exit 1
28
25
  }
29
26
 
30
27
  $WebdavUrl = $WebdavUrl.TrimEnd('/')
28
+ $backupUrl = "$WebdavUrl/backup/pi"
31
29
  $pair = "${User}:${Pass}"
32
30
  $auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair))
33
31
  $headers = @{ Authorization = "Basic $auth"; Depth = "1" }
34
32
 
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$' } |
33
+ Write-Host "[1/5] Listing Pi archives on WebDAV..." -ForegroundColor Cyan
34
+ $resp = Invoke-RestMethod -Uri "$backupUrl/" -Method PROPFIND -Headers $headers -ContentType "application/xml"
35
+ $hrefPattern = [regex]'(?i)<(?:[A-Za-z0-9_-]+:)?href>([^<]+)</(?:[A-Za-z0-9_-]+:)?href>'
36
+ $files = $hrefPattern.Matches([string]$resp) |
37
+ ForEach-Object { [Uri]::UnescapeDataString($_.Groups[1].Value) } |
38
+ Where-Object { $_ -match 'pi_agent_.*\.tar\.xz$' } |
42
39
  Sort-Object -Descending
43
40
 
44
41
  if ($files.Count -eq 0) {
45
- Write-Host "No backups found on WebDAV!" -ForegroundColor Red
42
+ Write-Host "No Pi archives found under backup/pi/." -ForegroundColor Red
46
43
  exit 1
47
44
  }
48
45
 
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
46
+ $name = [System.IO.Path]::GetFileName($files[0])
47
+ Write-Host "[2/5] Latest archive: $name" -ForegroundColor Green
56
48
 
57
- $tempDir = "$env:TEMP\pi_restore_$(Get-Date -Format 'yyyyMMddHHmmss')"
49
+ $tempArchive = Join-Path $env:TEMP $name
50
+ $tempDir = Join-Path $env:TEMP "pi_restore_$(Get-Date -Format 'yyyyMMddHHmmss')"
58
51
  New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
59
52
 
60
- Write-Host "[4/5] Extracting..." -ForegroundColor Cyan
61
- tar -xf $tempZip -C $tempDir
53
+ try {
54
+ Write-Host "[3/5] Downloading..." -ForegroundColor Cyan
55
+ Invoke-WebRequest -Uri "$backupUrl/$name" -Headers @{ Authorization = "Basic $auth" } -OutFile $tempArchive
62
56
 
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
- }
57
+ Write-Host "[4/5] Extracting..." -ForegroundColor Cyan
58
+ tar -xf $tempArchive -C $tempDir
59
+ if ($LASTEXITCODE -ne 0) { throw "tar extraction failed with exit code $LASTEXITCODE" }
79
60
 
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"
61
+ $agentDir = Join-Path $env:USERPROFILE ".pi\agent"
62
+ New-Item -ItemType Directory -Force -Path $agentDir | Out-Null
63
+ Get-ChildItem -LiteralPath $tempDir -Force | ForEach-Object {
64
+ Copy-Item -LiteralPath $_.FullName -Destination $agentDir -Recurse -Force
87
65
  }
88
- Copy-Item "$tempDir\skills" $skillsDest -Recurse
89
- Write-Host " Skills restored" -ForegroundColor Green
90
- }
91
66
 
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
67
+ Write-Host "[5/5] Pi agent archive restored to $agentDir" -ForegroundColor Green
68
+ Write-Host "Next: install/update packages from settings.json, then restart Pi." -ForegroundColor Cyan
69
+ } finally {
70
+ Remove-Item $tempArchive -Force -ErrorAction SilentlyContinue
71
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
102
72
  }
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)"
Binary file