@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
|
@@ -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
|
|
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
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
57
|
+
}
|
|
58
|
+
ensureDir(path.dirname(destination));
|
|
59
|
+
fs.copyFileSync(source, destination);
|
|
60
|
+
return 1;
|
|
72
61
|
}
|
|
73
62
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "WebDAV
|
|
5
|
-
"keywords": [
|
|
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
|
-
"
|
|
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": [
|
|
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
|
-
#
|
|
2
|
+
# Restore the latest trusted Pi agent archive from WebDAV on a new Windows machine.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
8
|
-
# $env:PI_WEBDAV_URL = "https://your-webdav.example/dav/
|
|
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
|
|
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
|
|
36
|
-
$resp = Invoke-RestMethod -Uri $
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
42
|
+
Write-Host "No Pi archives found under backup/pi/." -ForegroundColor Red
|
|
46
43
|
exit 1
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
$
|
|
50
|
-
$name
|
|
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
|
-
$
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
$
|
|
96
|
-
|
|
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)"
|
package/docs/sync-menu.png
DELETED
|
Binary file
|