import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { promises as fs } from "node:fs"; import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const CONFIG_ROOT = path.join(os.homedir(), ".pi", "agent"); const MANIFEST_NAME = "pi-export-config-manifest.json"; const BACKUP_DIR = path.join(CONFIG_ROOT, "backups"); const REMOTE_EXPORT_DIR = "~/.pi/agent/export-config"; const DEFAULT_GITHUB_REPO = "pi-config-backup"; const SENSITIVE_BASENAMES = new Set(["auth.json", "mcp.json"]); type SshAuth = { host: string; password?: string }; function stamp() { return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-"); } function generatePassword() { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; let out = ""; const bytes = crypto.randomBytes(12); for (const byte of bytes) out += alphabet[byte % alphabet.length]; return out; } function shellQuote(value: string) { return `'${value.replace(/'/g, `'\\''`)}'`; } function splitArgs(args: string) { const matches = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []; return matches.map((arg) => { if ((arg.startsWith('"') && arg.endsWith('"')) || (arg.startsWith("'") && arg.endsWith("'"))) return arg.slice(1, -1); return arg; }); } function looksLikeSshTarget(value: string) { return /^[^\s@]+@[^\s/]+$/.test(value) || /^[^\s/]+:\d+$/.test(value); } async function ensureTool(name: string) { try { await execFileAsync("bash", ["-lc", `command -v ${shellQuote(name)}`], { timeout: 5000 }); } catch { throw new Error(`Required command not found or not runnable: ${name}`); } } async function chmodSensitive(root: string) { async function walk(dir: string) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) await walk(full); else if (SENSITIVE_BASENAMES.has(entry.name) || /secret|token|credential|auth/i.test(entry.name)) await fs.chmod(full, 0o600).catch(() => undefined); } } await walk(root); } async function copyConfigToStage(stage: string) { const dest = path.join(stage, "agent"); await fs.mkdir(dest, { recursive: true, mode: 0o700 }); const entries = await fs.readdir(CONFIG_ROOT, { withFileTypes: true }); const included: string[] = []; for (const entry of entries) { if (["sessions", "backups", "export-config"].includes(entry.name)) continue; const src = path.join(CONFIG_ROOT, entry.name); const target = path.join(dest, entry.name); await execFileAsync("cp", ["-a", src, target], { timeout: 120000, maxBuffer: 1024 * 1024 }); if (entry.isDirectory()) { await execFileAsync("find", [target, "-type", "d", "-name", "node_modules", "-prune", "-exec", "rm", "-rf", "{}", ";"], { timeout: 120000, maxBuffer: 1024 * 1024 }).catch(() => undefined); } included.push(entry.name + (entry.isDirectory() ? "/" : "")); } return included.sort(); } async function writeManifest(stage: string, pi: ExtensionAPI | undefined, included: string[], cwd: string, encrypted: boolean) { const commands = pi?.getCommands?.() ?? []; const manifest = { format: "pi-export-config.v1", createdAt: new Date().toISOString(), sourceConfigRoot: CONFIG_ROOT, cwd, encrypted, encryption: encrypted ? "openssl enc -aes-256-cbc -salt -pbkdf2" : "none/private transport", included, extensionCommands: commands.filter((c: any) => c.source === "extension"), }; await fs.writeFile(path.join(stage, MANIFEST_NAME), JSON.stringify(manifest, null, 2), { mode: 0o600 }); } async function plainTar(stage: string, output: string) { await fs.mkdir(path.dirname(output), { recursive: true }); await execFileAsync("tar", ["-czf", output, "-C", stage, "."], { timeout: 120000, maxBuffer: 1024 * 1024 }); await fs.chmod(output, 0o600).catch(() => undefined); } async function createLocalPlainExport(pi: ExtensionAPI | undefined, cwd: string, output: string) { const stage = await fs.mkdtemp(path.join(os.tmpdir(), "pi-export-config-")); try { const included = await copyConfigToStage(stage); await writeManifest(stage, pi, included, cwd, false); await plainTar(stage, output); } finally { await fs.rm(stage, { recursive: true, force: true }); } } async function encryptedTar(stage: string, output: string, password: string) { await fs.mkdir(path.dirname(output), { recursive: true }); const cmd = `tar -czf - -C ${shellQuote(stage)} . | openssl enc -aes-256-cbc -salt -pbkdf2 -pass env:PI_EXPORT_PASSWORD -out ${shellQuote(output)}`; await execFileAsync("bash", ["-lc", cmd], { timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, PI_EXPORT_PASSWORD: password } }); await fs.chmod(output, 0o600).catch(() => undefined); } async function decryptArchive(archive: string, password: string, tarPath: string) { const cmd = `openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PI_EXPORT_PASSWORD -in ${shellQuote(archive)} -out ${shellQuote(tarPath)}`; await execFileAsync("bash", ["-lc", cmd], { timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, PI_EXPORT_PASSWORD: password } }); } async function validateAndExtract(tarPath: string, dest: string) { const { stdout } = await execFileAsync("tar", ["-tzf", tarPath], { timeout: 30000, maxBuffer: 10 * 1024 * 1024 }); const names = stdout.split("\n").filter(Boolean); if (!names.includes(`./${MANIFEST_NAME}`) && !names.includes(MANIFEST_NAME)) throw new Error("Archive is missing pi export manifest."); for (const name of names) { const normalized = path.posix.normalize(name.replace(/^\.\//, "")); if (normalized.startsWith("../") || normalized === ".." || path.posix.isAbsolute(name)) throw new Error(`Unsafe archive path: ${name}`); } await execFileAsync("tar", ["-xzf", tarPath, "-C", dest], { timeout: 120000, maxBuffer: 1024 * 1024 }); const manifest = JSON.parse(await fs.readFile(path.join(dest, MANIFEST_NAME), "utf8")); if (manifest.format !== "pi-export-config.v1") throw new Error("Unsupported pi export manifest format."); } async function backupCurrentConfig() { await fs.mkdir(BACKUP_DIR, { recursive: true, mode: 0o700 }); const backup = path.join(BACKUP_DIR, `export-config-backup-${stamp()}.tar.gz`); await execFileAsync("tar", ["--exclude=./backups", "--exclude=./sessions", "--exclude=./export-config", "-czf", backup, "-C", CONFIG_ROOT, "."], { timeout: 120000, maxBuffer: 1024 * 1024 }); await fs.chmod(backup, 0o600).catch(() => undefined); return backup; } async function cleanConfigRoot() { const entries = await fs.readdir(CONFIG_ROOT, { withFileTypes: true }); for (const entry of entries) { if (["sessions", "backups"].includes(entry.name)) continue; await fs.rm(path.join(CONFIG_ROOT, entry.name), { recursive: true, force: true }); } } async function sshExec(auth: SshAuth, remoteCommand: string, options: { timeout?: number; maxBuffer?: number } = {}) { const bashCommand = `bash -lc ${shellQuote(remoteCommand)}`; const sshArgs = ["-o", "StrictHostKeyChecking=accept-new", auth.host, bashCommand]; if (auth.password) { await ensureTool("sshpass"); return execFileAsync("sshpass", ["-p", auth.password, "ssh", ...sshArgs], { timeout: options.timeout ?? 30000, maxBuffer: options.maxBuffer ?? 1024 * 1024 }); } return execFileAsync("ssh", sshArgs, { timeout: options.timeout ?? 30000, maxBuffer: options.maxBuffer ?? 1024 * 1024 }); } async function authenticateSsh(host: string, ctx: any): Promise { await ensureTool("ssh"); try { await execFileAsync("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=accept-new", host, "bash -lc true"], { timeout: 12000 }); return { host }; } catch { const password = await ctx.ui.input("SSH password", `Public-key authentication failed for ${host}. Enter SSH password (requires sshpass locally):`); if (!password) return undefined; await ensureTool("sshpass"); await sshExec({ host, password }, "true", { timeout: 12000 }); return { host, password }; } } async function listRemoteExports(auth: SshAuth) { const cmd = `mkdir -p ${REMOTE_EXPORT_DIR} && find ${REMOTE_EXPORT_DIR} -maxdepth 1 -type f -name 'pi-config-export-*.tar.gz' -printf '%f\t%TY-%Tm-%Td %TH:%TM\t%s\n' 2>/dev/null | sort -r`; const { stdout } = await sshExec(auth, cmd, { maxBuffer: 5 * 1024 * 1024 }); return stdout.split("\n").filter(Boolean).map((line) => { const [file, modified, size] = line.split("\t"); return { file, modified, size }; }); } async function createRemotePlainExport(auth: SshAuth) { const remoteScript = `set -e mkdir -p ~/.pi/agent/export-config stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT mkdir -p "$stage/agent" cd ~/.pi/agent for item in * .[^.]*; do [ -e "$item" ] || continue case "$item" in sessions|backups|export-config|node_modules) continue;; esac cp -a "$item" "$stage/agent/" done cat > "$stage/${MANIFEST_NAME}" <<'JSON' {"format":"pi-export-config.v1","createdAt":"REMOTE_CREATED_AT","sourceConfigRoot":"~/.pi/agent","cwd":"remote","encrypted":false,"encryption":"none/ssh transport","included":[]} JSON sed -i "s/REMOTE_CREATED_AT/$(date -u +%Y-%m-%dT%H:%M:%SZ)/" "$stage/${MANIFEST_NAME}" out="$HOME/.pi/agent/export-config/pi-config-export-$(date -u +%Y%m%d-%H%M%SZ).tar.gz" tar -czf "$out" -C "$stage" . chmod 600 "$out" printf '%s' "$out"`; const { stdout } = await sshExec(auth, remoteScript, { timeout: 120000, maxBuffer: 1024 * 1024 }); return stdout.trim(); } async function downloadRemoteExport(auth: SshAuth, remoteFile: string, localPath: string) { await fs.mkdir(path.dirname(localPath), { recursive: true }); const remoteCommand = `bash -lc ${shellQuote(`cat ${REMOTE_EXPORT_DIR}/${remoteFile}`)}`; const cmd = `ssh -o StrictHostKeyChecking=accept-new ${shellQuote(auth.host)} ${shellQuote(remoteCommand)} > ${shellQuote(localPath)}`; if (auth.password) { await ensureTool("sshpass"); await execFileAsync("bash", ["-lc", `sshpass -p "$SSHPASS" ${cmd}`], { timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, SSHPASS: auth.password } }); } else { await execFileAsync("bash", ["-lc", cmd], { timeout: 120000, maxBuffer: 1024 * 1024 }); } await fs.chmod(localPath, 0o600).catch(() => undefined); } async function importPlainTar(tarPath: string, ctx: any, clean: boolean) { const extractDir = path.join(path.dirname(tarPath), "extract"); await fs.mkdir(extractDir, { recursive: true, mode: 0o700 }); await validateAndExtract(tarPath, extractDir); const importedAgent = path.join(extractDir, "agent"); if (!existsSync(importedAgent)) throw new Error("Archive does not contain an agent/ config directory."); const backup = await backupCurrentConfig(); if (clean) await cleanConfigRoot(); await execFileAsync("cp", ["-a", `${importedAgent}/.`, CONFIG_ROOT], { timeout: 120000, maxBuffer: 1024 * 1024 }); await chmodSensitive(CONFIG_ROOT); const reload = await ctx.ui.confirm("Reload pi?", `Import complete. Backup created at ${backup}. Reload extensions/config now?`); if (reload) await ctx.reload(); else ctx.ui.notify(`Imported pi config. Backup: ${backup}`, "success"); } async function ensureGitHubCli() { await ensureTool("gh"); await ensureTool("git"); try { await execFileAsync("gh", ["auth", "status"], { timeout: 15000, maxBuffer: 1024 * 1024 }); } catch { throw new Error("GitHub CLI is not authenticated. Run: gh auth login"); } } function normalizeRepo(input: string | undefined, owner: string) { const repo = (input || DEFAULT_GITHUB_REPO).trim(); return repo.includes("/") ? repo : `${owner}/${repo}`; } async function githubOwner() { const { stdout } = await execFileAsync("gh", ["api", "user", "--jq", ".login"], { timeout: 15000 }); return stdout.trim(); } async function ensurePrivateRepo(fullRepo: string) { try { const { stdout } = await execFileAsync("gh", ["repo", "view", fullRepo, "--json", "isPrivate", "--jq", ".isPrivate"], { timeout: 20000 }); if (stdout.trim() !== "true") throw new Error(`GitHub repository ${fullRepo} exists but is not private. Refusing to upload secrets.`); } catch (error: any) { if (!String(error?.message ?? error).includes("Could not resolve") && !String(error?.stderr ?? "").includes("Could not resolve")) throw error; await execFileAsync("gh", ["repo", "create", fullRepo, "--private", "--description", "Private pi configuration backup", "--confirm"], { timeout: 60000, maxBuffer: 1024 * 1024 }); } } async function cloneGithubRepo(fullRepo: string, dir: string) { await execFileAsync("gh", ["repo", "clone", fullRepo, dir], { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }); } async function listGithubExports(repoDir: string) { const exportsDir = path.join(repoDir, "exports"); if (!existsSync(exportsDir)) return [] as Array<{ file: string; mtime: number; size: number }>; const entries = await fs.readdir(exportsDir, { withFileTypes: true }); const files = [] as Array<{ file: string; mtime: number; size: number }>; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".tar.gz")) continue; const stat = await fs.stat(path.join(exportsDir, entry.name)); files.push({ file: entry.name, mtime: stat.mtimeMs, size: stat.size }); } return files.sort((a, b) => b.mtime - a.mtime); } function githubInstallScript(fullRepo: string) { return `#!/usr/bin/env bash set -euo pipefail REPO="\${PI_CONFIG_REPO:-${fullRepo}}" REF="\${PI_CONFIG_REF:-main}" PI_PACKAGE="\${PI_PACKAGE:-@mariozechner/pi-coding-agent}" CONFIG_ROOT="\${PI_CONFIG_ROOT:-$HOME/.pi/agent}" log() { printf '[pi-config-install] %s\\n' "$*" >&2; } fatal() { printf '[pi-config-install] ERROR: %s\\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } as_root() { if [ "$(id -u)" -eq 0 ]; then "$@"; elif have sudo; then sudo "$@"; else fatal "Need root to install packages. Install sudo or run as root."; fi; } pm_install() { [ "$#" -gt 0 ] || return 0 if have apt-get; then as_root apt-get update; as_root apt-get install -y "$@" elif have dnf; then as_root dnf install -y "$@" elif have yum; then as_root yum install -y "$@" elif have pacman; then as_root pacman -Sy --needed --noconfirm "$@" elif have zypper; then as_root zypper --non-interactive install "$@" elif have apk; then as_root apk add --no-cache "$@" else fatal "Unsupported distro: install missing tools manually: $*" fi } install_base_tools() { local missing=() have curl || missing+=(curl) have tar || missing+=(tar) have gzip || missing+=(gzip) have git || missing+=(git) if ! have node || ! have npm; then if have apk; then missing+=(nodejs npm); else missing+=(nodejs npm); fi fi [ "\${#missing[@]}" -eq 0 ] || pm_install "\${missing[@]}" } install_gh() { have gh && return 0 log "Installing GitHub CLI" if have apt-get; then pm_install ca-certificates curl gnupg as_root mkdir -p /etc/apt/keyrings curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | as_root tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null as_root chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | as_root tee /etc/apt/sources.list.d/github-cli.list >/dev/null as_root apt-get update && as_root apt-get install -y gh elif have dnf; then as_root dnf install -y 'dnf-command(config-manager)'; as_root dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo; as_root dnf install -y gh elif have yum; then as_root yum install -y yum-utils; as_root yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo; as_root yum install -y gh else pm_install gh || true fi have gh || fatal "Could not install gh. Install GitHub CLI or set GITHUB_TOKEN." } fetch_repo_file() { local remote_path="$1" out="$2" if [ -n "\${GITHUB_TOKEN:-}" ]; then curl -fsSL -H "Authorization: Bearer \${GITHUB_TOKEN}" -H "Accept: application/vnd.github.raw" \ "https://api.github.com/repos/\${REPO}/contents/\${remote_path}?ref=\${REF}" -o "$out" else install_gh gh auth status >/dev/null 2>&1 || fatal "Private repo access needs GitHub auth. Run 'gh auth login' or set GITHUB_TOKEN." gh api "repos/\${REPO}/contents/\${remote_path}?ref=\${REF}" -H "Accept: application/vnd.github.raw" > "$out" fi } install_pi() { if have pi; then log "pi already installed: $(command -v pi)"; return 0; fi log "Installing pi via npm: \${PI_PACKAGE}" if npm install -g "\${PI_PACKAGE}"; then return 0; fi as_root npm install -g "\${PI_PACKAGE}" have pi || fatal "pi install completed but pi is not on PATH" } safe_extract() { local archive="$1" dest="$2" tar -tzf "$archive" | while IFS= read -r name; do case "$name" in /*|../*|*/../*|..|*/..) fatal "Unsafe archive path: $name";; esac done tar -xzf "$archive" -C "$dest" [ -f "$dest/${MANIFEST_NAME}" ] || fatal "Archive missing ${MANIFEST_NAME}" grep -q '"format"[[:space:]]*:[[:space:]]*"pi-export-config.v1"' "$dest/${MANIFEST_NAME}" || fatal "Unsupported manifest format" [ -d "$dest/agent" ] || fatal "Archive missing agent/ config directory" } backup_and_import() { local extracted="$1" stamp backup mkdir -p "$CONFIG_ROOT/backups" stamp="$(date -u +%Y%m%d-%H%M%SZ)" backup="$CONFIG_ROOT/backups/install-import-backup-$stamp.tar.gz" if [ -d "$CONFIG_ROOT" ]; then tar --exclude=./backups --exclude=./sessions --exclude=./export-config -czf "$backup" -C "$CONFIG_ROOT" . 2>/dev/null || true chmod 600 "$backup" 2>/dev/null || true find "$CONFIG_ROOT" -mindepth 1 -maxdepth 1 ! -name sessions ! -name backups -exec rm -rf {} + fi mkdir -p "$CONFIG_ROOT" cp -a "$extracted/agent/." "$CONFIG_ROOT/" find "$CONFIG_ROOT" -type f \( -iname '*secret*' -o -iname '*token*' -o -iname '*credential*' -o -iname '*auth*' -o -name auth.json -o -name mcp.json \) -exec chmod 600 {} + 2>/dev/null || true log "Imported pi config into $CONFIG_ROOT" log "Backup: $backup" } main() { [ -n "$REPO" ] || fatal "Set PI_CONFIG_REPO=owner/repo" install_base_tools install_pi local work latest archive work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT fetch_repo_file latest.txt "$work/latest.txt" latest="$(tr -d '\\r\\n' < "$work/latest.txt")" [ -n "$latest" ] || fatal "latest.txt is empty" archive="$work/$latest" log "Downloading $REPO:$REF exports/$latest" fetch_repo_file "exports/$latest" "$archive" mkdir -p "$work/extract" safe_extract "$archive" "$work/extract" backup_and_import "$work/extract" log "Done. Start pi with: pi" } main "$@" `; } function githubInstallPowerShellScript(fullRepo: string) { return `#requires -Version 5.1 $ErrorActionPreference = "Stop" $Repo = if ($env:PI_CONFIG_REPO) { $env:PI_CONFIG_REPO } else { "${fullRepo}" } $Ref = if ($env:PI_CONFIG_REF) { $env:PI_CONFIG_REF } else { "main" } $PiPackage = if ($env:PI_PACKAGE) { $env:PI_PACKAGE } else { "@mariozechner/pi-coding-agent" } $ConfigRoot = if ($env:PI_CONFIG_ROOT) { $env:PI_CONFIG_ROOT } else { Join-Path $env:USERPROFILE ".pi\\agent" } function Write-Log([string]$Message) { Write-Host "[pi-config-install] $Message" } function Throw-Fatal([string]$Message) { throw "[pi-config-install] ERROR: $Message" } function Test-Command([string]$Name) { return [bool](Get-Command $Name -ErrorAction SilentlyContinue) } function Install-WithWinget([string]$Id, [string]$Name) { if (-not (Test-Command winget)) { Throw-Fatal "$Name is required. Install it manually or install winget first." } Write-Log "Installing $Name with winget" winget install --id $Id --exact --accept-package-agreements --accept-source-agreements } function Ensure-BaseTools { if (-not (Test-Command node) -or -not (Test-Command npm)) { Install-WithWinget "OpenJS.NodeJS.LTS" "Node.js/npm" } if (-not (Test-Command tar)) { Throw-Fatal "tar.exe is required to extract .tar.gz exports. Use Windows 10+, Git for Windows, or bsdtar." } } function Ensure-Gh { if (Test-Command gh) { return } if ($env:GITHUB_TOKEN) { return } Install-WithWinget "GitHub.cli" "GitHub CLI" } function Fetch-RepoFile([string]$RemotePath, [string]$OutFile) { if ($env:GITHUB_TOKEN) { Invoke-WebRequest -Uri "https://api.github.com/repos/$Repo/contents/$RemotePath\`?ref=$Ref" -Headers @{ Authorization = "Bearer $env:GITHUB_TOKEN" Accept = "application/vnd.github.raw" } -OutFile $OutFile } else { Ensure-Gh gh auth status *> $null if ($LASTEXITCODE -ne 0) { Throw-Fatal "Private repo access needs GitHub auth. Run 'gh auth login' or set GITHUB_TOKEN." } $token = (gh auth token).Trim() Invoke-WebRequest -Uri "https://api.github.com/repos/$Repo/contents/$RemotePath\`?ref=$Ref" -Headers @{ Authorization = "Bearer $token" Accept = "application/vnd.github.raw" } -OutFile $OutFile } } function Install-Pi { if (Test-Command pi) { Write-Log "pi already installed: $((Get-Command pi).Source)"; return } Write-Log "Installing pi via npm: $PiPackage" npm install -g $PiPackage if (-not (Test-Command pi)) { Throw-Fatal "pi install completed but pi is not on PATH. Restart PowerShell or add npm global bin to PATH." } } function Test-SafeTar([string]$Archive) { $entries = tar -tzf $Archive if ($LASTEXITCODE -ne 0) { Throw-Fatal "Unable to list archive entries." } foreach ($name in $entries) { if ($name -match '^[A-Za-z]:[\\/]' -or $name.StartsWith('/') -or $name.StartsWith('\\') -or $name -eq '..' -or $name.StartsWith('../') -or $name -match '(^|/|\\)\.\.($|/|\\)') { Throw-Fatal "Unsafe archive path: $name" } } } function Expand-SafeArchive([string]$Archive, [string]$Dest) { Test-SafeTar $Archive tar -xzf $Archive -C $Dest if ($LASTEXITCODE -ne 0) { Throw-Fatal "Unable to extract archive." } $manifest = Join-Path $Dest "${MANIFEST_NAME}" if (-not (Test-Path $manifest)) { Throw-Fatal "Archive missing ${MANIFEST_NAME}" } $json = Get-Content $manifest -Raw | ConvertFrom-Json if ($json.format -ne "pi-export-config.v1") { Throw-Fatal "Unsupported manifest format" } if (-not (Test-Path (Join-Path $Dest "agent"))) { Throw-Fatal "Archive missing agent/ config directory" } } function Backup-AndImport([string]$Extracted) { New-Item -ItemType Directory -Force -Path (Join-Path $ConfigRoot "backups") | Out-Null $stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmssZ") $backup = Join-Path (Join-Path $ConfigRoot "backups") "install-import-backup-$stamp.tar.gz" if (Test-Path $ConfigRoot) { tar --exclude=./backups --exclude=./sessions --exclude=./export-config -czf $backup -C $ConfigRoot . 2>$null Get-ChildItem -LiteralPath $ConfigRoot -Force | Where-Object { $_.Name -notin @("sessions", "backups") } | Remove-Item -Recurse -Force } New-Item -ItemType Directory -Force -Path $ConfigRoot | Out-Null Copy-Item -Path (Join-Path $Extracted "agent\\*") -Destination $ConfigRoot -Recurse -Force Write-Log "Imported pi config into $ConfigRoot" Write-Log "Backup: $backup" } function Main { if (-not $Repo) { Throw-Fatal "Set PI_CONFIG_REPO=owner/repo" } Ensure-BaseTools Install-Pi $work = Join-Path ([IO.Path]::GetTempPath()) ("pi-config-install-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $work | Out-Null try { $latestPath = Join-Path $work "latest.txt" Fetch-RepoFile "latest.txt" $latestPath $latest = (Get-Content $latestPath -Raw).Trim() if (-not $latest) { Throw-Fatal "latest.txt is empty" } $archive = Join-Path $work $latest Write-Log "Downloading \${Repo}:$Ref exports/$latest" Fetch-RepoFile "exports/$latest" $archive $extract = Join-Path $work "extract" New-Item -ItemType Directory -Force -Path $extract | Out-Null Expand-SafeArchive $archive $extract Backup-AndImport $extract Write-Log "Done. Start pi with: pi" } finally { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } } Main `; } export default function (pi: ExtensionAPI) { pi.registerCommand("export-config", { description: "Export ~/.pi/agent config to a password-protected archive. Prints a generated 12-char password.", handler: async (args, ctx) => { await ctx.waitForIdle(); await ensureTool("tar"); await ensureTool("openssl"); const [requestedPath] = splitArgs(args ?? ""); const output = path.resolve(ctx.cwd, requestedPath || `pi-config-export-${stamp()}.tar.gz.enc`); const ok = await ctx.ui.confirm("Export pi config?", "This encrypted archive will include extension secrets, API keys, auth files, tokens, and other sensitive pi config. Continue?"); if (!ok) return; const stage = await fs.mkdtemp(path.join(os.tmpdir(), "pi-export-config-")); try { const included = await copyConfigToStage(stage); await writeManifest(stage, pi, included, ctx.cwd, true); const password = generatePassword(); await encryptedTar(stage, output, password); ctx.ui.notify(`Exported pi config to ${output}\nPassword: ${password}\nStore this password securely; it is required by /import-config.`, "success"); } finally { await fs.rm(stage, { recursive: true, force: true }); } }, }); pi.registerCommand("import-config", { description: "Import local encrypted archive or SSH source: /import-config OR /import-config user@host", handler: async (args, ctx) => { await ctx.waitForIdle(); await ensureTool("tar"); const parts = splitArgs(args ?? ""); const [source, password] = parts; if (!source) { ctx.ui.notify("Usage: /import-config OR /import-config user@host", "error"); return; } if (looksLikeSshTarget(source) && !password) { try { const auth = await authenticateSsh(source, ctx); if (!auth) return; let exports = await listRemoteExports(auth); if (exports.length === 0) { const create = await ctx.ui.confirm("No remote exports", `No unencrypted SSH exports found on ${source}. Create one from that machine's ~/.pi/agent now?`); if (!create) return; await createRemotePlainExport(auth); exports = await listRemoteExports(auth); } const createNewLabel = "Create a fresh remote export now"; const choices = [createNewLabel, ...exports.map((e) => `${e.file} (${e.modified}, ${e.size} bytes)`)]; const choice = await ctx.ui.select("Choose remote pi config to import", choices); if (!choice) return; let remoteFile = exports[choices.indexOf(choice) - 1]?.file; if (choice === createNewLabel) { const full = await createRemotePlainExport(auth); remoteFile = path.posix.basename(full); } if (!remoteFile) return; const clean = await ctx.ui.confirm("Clean import?", "Reset local ~/.pi/agent to stock/empty config before importing? Existing config is backed up first; sessions/backups are preserved."); const ok = await ctx.ui.confirm("Import remote pi config?", `Import ${remoteFile} from ${source}${clean ? " with clean reset" : ""}?`); if (!ok) return; const work = await fs.mkdtemp(path.join(os.tmpdir(), "pi-import-ssh-config-")); try { const localTar = path.join(work, remoteFile); await downloadRemoteExport(auth, remoteFile, localTar); await importPlainTar(localTar, ctx, clean); } finally { await fs.rm(work, { recursive: true, force: true }); } } catch (error: any) { ctx.ui.notify(`SSH import failed: ${error?.message ?? String(error)}`, "error"); } return; } await ensureTool("openssl"); if (!password) { ctx.ui.notify("Usage: /import-config ", "error"); return; } const archive = path.resolve(ctx.cwd, source); if (!existsSync(archive)) { ctx.ui.notify(`Archive not found: ${archive}`, "error"); return; } const clean = await ctx.ui.confirm("Clean import?", "Reset local ~/.pi/agent to stock/empty config before importing? Existing config is backed up first; sessions/backups are preserved."); const ok = await ctx.ui.confirm("Import pi config?", "This will overwrite files in ~/.pi/agent with the archive contents. A backup will be created first. Continue?"); if (!ok) return; const work = await fs.mkdtemp(path.join(os.tmpdir(), "pi-import-config-")); try { const tarPath = path.join(work, "config.tar.gz"); await decryptArchive(archive, password, tarPath); await importPlainTar(tarPath, ctx, clean); } catch (error: any) { ctx.ui.notify(`Import failed: ${error?.message ?? String(error)}`, "error"); } finally { await fs.rm(work, { recursive: true, force: true }); } }, }); pi.registerCommand("export-config-github", { description: "Upload current pi config to a private GitHub repo using gh. Usage: /export-config-github [owner/repo|repo]", handler: async (args, ctx) => { await ctx.waitForIdle(); await ensureTool("tar"); await ensureGitHubCli(); const owner = await githubOwner(); const [repoArg] = splitArgs(args ?? ""); const fullRepo = normalizeRepo(repoArg, owner); const ok = await ctx.ui.confirm("Upload pi config to GitHub?", `This uploads secrets/API keys/auth files to private repo ${fullRepo}. Continue?`); if (!ok) return; const work = await fs.mkdtemp(path.join(os.tmpdir(), "pi-config-github-export-")); try { await ensurePrivateRepo(fullRepo); const repoDir = path.join(work, "repo"); await cloneGithubRepo(fullRepo, repoDir); await fs.mkdir(path.join(repoDir, "exports"), { recursive: true }); await fs.mkdir(path.join(repoDir, "manifests"), { recursive: true }); const name = `pi-config-export-${stamp()}.tar.gz`; const archive = path.join(repoDir, "exports", name); await createLocalPlainExport(pi, ctx.cwd, archive); const inspectDir = path.join(work, "inspect"); await fs.mkdir(inspectDir, { recursive: true }); await validateAndExtract(archive, inspectDir); await fs.copyFile(path.join(inspectDir, MANIFEST_NAME), path.join(repoDir, "manifests", name.replace(/\.tar\.gz$/, ".json"))); await fs.writeFile(path.join(repoDir, "latest.txt"), `${name}\n`); const installPath = path.join(repoDir, "install.sh"); if (!existsSync(installPath)) { await fs.writeFile(installPath, githubInstallScript(fullRepo), { mode: 0o755 }); await fs.chmod(installPath, 0o755).catch(() => undefined); } const installPsPath = path.join(repoDir, "install.ps1"); if (!existsSync(installPsPath)) { await fs.writeFile(installPsPath, githubInstallPowerShellScript(fullRepo)); } const readme = `# pi config backup\n\nPrivate repository created by pi export-config extension.\n\nArchives in \`exports/\` contain sensitive pi configuration and secrets. Keep this repository private.\n\n## Install pi and import latest config\n\nThis repository is private, so fetch the installer with an authenticated request.\n\n### Linux/macOS\n\nWith GitHub CLI:\n\n\`\`\`bash\ngh auth login\ngh api repos/${fullRepo}/contents/install.sh -H 'Accept: application/vnd.github.raw' | bash\n\`\`\`\n\nOr with a token:\n\n\`\`\`bash\nGITHUB_TOKEN=ghp_...\ncurl -fsSL -H \"Authorization: Bearer \${GITHUB_TOKEN}\" -H 'Accept: application/vnd.github.raw' \\\n https://api.github.com/repos/${fullRepo}/contents/install.sh | bash\n\`\`\`\n\n### Windows PowerShell\n\nWith GitHub CLI:\n\n\`\`\`powershell\ngh auth login\ngh api repos/${fullRepo}/contents/install.ps1 -H 'Accept: application/vnd.github.raw' | iex\n\`\`\`\n\nOr with a token:\n\n\`\`\`powershell\n$env:GITHUB_TOKEN = \"ghp_...\"\nirm https://api.github.com/repos/${fullRepo}/contents/install.ps1 -Headers @{ Authorization = \"Bearer $env:GITHUB_TOKEN\"; Accept = \"application/vnd.github.raw\" } | iex\n\`\`\`\n\nSafer inspect-first flow:\n\n\`\`\`powershell\ngh api repos/${fullRepo}/contents/install.ps1 -H 'Accept: application/vnd.github.raw' > install.ps1\npowershell -ExecutionPolicy Bypass -File .\\install.ps1\n\`\`\`\n\nIf the script was downloaded from another branch/ref, set \`PI_CONFIG_REF\`.\n`; if (!existsSync(path.join(repoDir, "README.md"))) await fs.writeFile(path.join(repoDir, "README.md"), readme); await execFileAsync("git", ["-C", repoDir, "add", "README.md", "install.sh", "install.ps1", "latest.txt", "exports", "manifests"], { timeout: 30000 }); await execFileAsync("git", ["-C", repoDir, "commit", "-m", `Add pi config export ${name}`], { timeout: 30000, maxBuffer: 1024 * 1024 }).catch(async (error) => { const msg = String(error?.stdout ?? "") + String(error?.stderr ?? ""); if (!msg.includes("nothing to commit")) throw error; }); await execFileAsync("git", ["-C", repoDir, "push"], { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }); ctx.ui.notify(`Uploaded ${name} to private GitHub repo ${fullRepo}.`, "success"); } catch (error: any) { ctx.ui.notify(`GitHub export failed: ${error?.message ?? String(error)}`, "error"); } finally { await fs.rm(work, { recursive: true, force: true }); } }, }); pi.registerCommand("import-config-github", { description: "Pull and apply pi config from a private GitHub repo using gh. Usage: /import-config-github [owner/repo|repo]", handler: async (args, ctx) => { await ctx.waitForIdle(); await ensureTool("tar"); await ensureGitHubCli(); const owner = await githubOwner(); const [repoArg] = splitArgs(args ?? ""); const fullRepo = normalizeRepo(repoArg, owner); const work = await fs.mkdtemp(path.join(os.tmpdir(), "pi-config-github-import-")); try { const repoDir = path.join(work, "repo"); await cloneGithubRepo(fullRepo, repoDir); const exports = await listGithubExports(repoDir); if (exports.length === 0) { ctx.ui.notify(`No exports found in ${fullRepo}/exports.`, "error"); return; } const latestPath = path.join(repoDir, "latest.txt"); const latest = existsSync(latestPath) ? (await fs.readFile(latestPath, "utf8")).trim() : ""; const choices = exports.map((e) => `${e.file}${e.file === latest ? " (latest)" : ""} (${new Date(e.mtime).toISOString()}, ${e.size} bytes)`); const choice = await ctx.ui.select("Choose GitHub pi config to import", choices); if (!choice) return; const selected = exports[choices.indexOf(choice)]; if (!selected) return; const clean = await ctx.ui.confirm("Clean import?", "Reset local ~/.pi/agent to stock/empty config before importing? Existing config is backed up first; sessions/backups are preserved."); const ok = await ctx.ui.confirm("Import GitHub pi config?", `Import ${selected.file} from ${fullRepo}${clean ? " with clean reset" : ""}?`); if (!ok) return; await importPlainTar(path.join(repoDir, "exports", selected.file), ctx, clean); } catch (error: any) { ctx.ui.notify(`GitHub import failed: ${error?.message ?? String(error)}`, "error"); } finally { await fs.rm(work, { recursive: true, force: true }); } }, }); }