// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. import { createSyncBackend, formatRemoteSelectionStatus, readSnapshotForHead, requireCompatibleRemoteSelection } from "./chunk-FR6CVXAL.ts"; import { applySnapshotTransaction, recoverPendingSnapshotTransactions } from "./chunk-3NDD5QPM.ts"; import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "./chunk-LJDSHNS3.ts"; import { SyncDecisionRequiredError, countPreservedRemoteFiles, errorMessage, formatDiff, formatPullSummary, formatPushSummary, formatRollbackSummary, formatSnapshotOnlyDiff, publicationCapabilityDescription, safeTerminalText } from "./chunk-W64NEHXT.ts"; import { canPullRemoteSessionsOnFirstSync, canPullRemoteSettingsOnFirstSync, fileHashMap, hasLocalChanges, hasRemoteChanges, remoteChangedSinceState, sameHashes, shouldRefreshSyncedState, snapshotHashesMatchState, snapshotsMatch, syncPolicyChanged } from "./chunk-RGJ6SOYR.ts"; import { createSnapshot, filterSnapshotForConfigPolicy, isSessionFilePath, isSessionPath, mergeRemotePreservedFiles, regenerateSnapshotIdentity, scanSnapshot, sessionSnapshotPathFromAbsolute, sessionStorageRoot, snapshotIncludesSessions, snapshotTarget, snapshotWithoutSessions } from "./chunk-EWX3TPLQ.ts"; import { agentDir, loadConfig, readStateForConfig, sessionDirForApply, syncConfigReviewFingerprint, syncConfigReviewIdentity, syncSessionsWarnings, writeStateForConfig } from "./chunk-APZVBSM6.ts"; import { encodeSnapshot } from "./chunk-HGV24P4T.ts"; import { stateDir } from "./chunk-F5QL2GPY.ts"; import { expectedRemoteHead } from "./chunk-5MQ76BK5.ts"; import { assertWithinRoot, includeFromSelectionConfig, inspectRemoteSelection, isDeniedPath, isPathInside, parentPaths, remoteSelectionMismatch, safeJoin, toPosix } from "./chunk-YQ6UW7IF.ts"; // src/sync-operations.ts import fs2 from "node:fs/promises"; import path2 from "node:path"; // src/snapshot-apply.ts import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; function sha256(value) { return createHash("sha256").update(value).digest("hex"); } function fileHashMap2(snapshot) { return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256])); } async function applySnapshot(snapshot, protectedRelativePaths = /* @__PURE__ */ new Set(), options = {}) { const root = agentDir(); const { sessionDir } = options; await recoverPendingSnapshotTransactions(); const current = await createSnapshot(snapshot.profile, { ...options, ...options.include === void 0 ? { syncSessions: snapshotIncludesSessions(snapshot) } : {}, sessionDir }); const plan = await addTopLevelCaseVariantDeletes( root, protectSnapshotApplyPlan( root, preflightSnapshotApply(root, snapshot, current, { sessionDir }), protectedRelativePaths, sessionDir ), snapshot ); await preflightSnapshotMutations(root, plan, sessionDir); await applySnapshotTransaction(plan, { sessionDir }); return appliedFileHashMap(snapshot, current, protectedRelativePaths); } function preflightSnapshotApply(root, snapshot, current, options = {}) { const seenPaths = /* @__PURE__ */ new Set(); const remotePaths = /* @__PURE__ */ new Set(); const writes = []; const deletes = []; for (const file of snapshot.files) { const normalized = toPosix(file.path); if (!isSafeSnapshotPath(file.path)) { throw new Error(`Unsafe path in snapshot: ${file.path}`); } if (isSessionPath(normalized) && !isSessionFilePath(normalized)) { throw new Error(`Unsafe session path in snapshot: ${file.path}`); } if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`); seenPaths.add(normalized); remotePaths.add(normalized); const target = snapshotTarget(root, normalized, options.sessionDir); const content = decodeBase64Strict(file.contentBase64, normalized); if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`); writes.push({ target, content }); } const deletePaths = /* @__PURE__ */ new Set(); for (const file of current.files) { const normalized = toPosix(file.path); if (!remotePaths.has(normalized)) { deletePaths.add(snapshotTarget(root, normalized, options.sessionDir)); } for (const remotePath of parentPaths(normalized)) { if (remotePaths.has(remotePath)) { deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir)); } } } deletes.push(...deletePaths); return { writes, deletes }; } function protectSnapshotApplyPlan(root, plan, protectedRelativePaths, sessionDir) { if (protectedRelativePaths.size === 0) return plan; const protectedTargets = new Set( [...protectedRelativePaths].map( (relativePath) => snapshotTarget(root, relativePath, sessionDir) ) ); return { writes: plan.writes.filter((item) => !protectedTargets.has(item.target)), deletes: plan.deletes.filter((target) => !protectedTargets.has(target)) }; } async function addTopLevelCaseVariantDeletes(root, plan, snapshot) { const topLevelPaths = /* @__PURE__ */ new Map(); for (const file of snapshot.files) { const normalized = toPosix(file.path); if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) { topLevelPaths.set(normalized.toLowerCase(), normalized); } } if (topLevelPaths.size === 0) return plan; let entries; try { entries = await fs.readdir(root, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return plan; throw error; } const deletes = new Set(plan.deletes); for (const entry of entries) { const canonicalPath = topLevelPaths.get(entry.name.toLowerCase()); if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) { deletes.add(safeJoin(root, entry.name)); } } return { ...plan, deletes: [...deletes] }; } function appliedFileHashMap(snapshot, current, protectedRelativePaths) { const hashes = fileHashMap2(snapshot); if (protectedRelativePaths.size === 0) return hashes; const currentHashes = fileHashMap2(current); for (const relativePath of protectedRelativePaths) { const normalized = toPosix(relativePath); if (currentHashes[normalized]) { hashes[normalized] = currentHashes[normalized]; } else { delete hashes[normalized]; } } return hashes; } function isSafeSnapshotPath(relativePath) { if (relativePath.includes("\\")) return false; const normalized = toPosix(relativePath); return Boolean(normalized) && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") && !path.posix.isAbsolute(normalized) && path.posix.normalize(normalized) === normalized && !isDeniedPath(normalized); } function decodeBase64Strict(value, filePath) { if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) { throw new Error(`Invalid base64 content in snapshot file: ${filePath}`); } return Buffer.from(value, "base64"); } async function preflightSnapshotMutations(root, plan, sessionDir) { const deletePaths = new Set(plan.deletes); for (const target of plan.deletes) { await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target); } for (const item of plan.writes) { await prepareSnapshotWrite( rootForTarget(root, item.target, sessionDir), item.target, deletePaths ); } } function rootForTarget(root, target, sessionDir) { const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : void 0; if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot; return root; } async function prepareSnapshotWrite(root, target, deletePaths) { const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths); if (parentWillBeReplaced) return; try { const stat = await fs.lstat(target); if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`); if (stat.isDirectory() && !deletePaths.has(target)) { throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`); } } catch (error) { if (error.code !== "ENOENT") throw error; } } async function ensureSafeDirectory(root, directory, deletePaths) { assertWithinRoot(root, directory); const rootPath = path.resolve(root); const relative = path.relative(rootPath, path.resolve(directory)); let current = rootPath; for (const part of relative.split(path.sep).filter(Boolean)) { current = path.join(current, part); try { const stat = await fs.lstat(current); if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`); if (!stat.isDirectory()) { if (deletePaths.has(current)) return true; throw new Error(`Snapshot path parent is not a directory: ${current}`); } } catch (error) { if (error.code !== "ENOENT") throw error; await fs.mkdir(current); } } return false; } async function assertNoSymlinkParents(root, target) { assertWithinRoot(root, target); const rootPath = path.resolve(root); const relative = path.relative(rootPath, path.resolve(target)); let current = rootPath; const parts = relative.split(path.sep).filter(Boolean); for (const part of parts.slice(0, -1)) { current = path.join(current, part); try { const stat = await fs.lstat(current); if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`); if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`); } catch (error) { if (error.code === "ENOENT") return; throw error; } } } // src/sync-decision.ts function createSyncDecision(options) { const { config, state, local, remote, kind } = options; const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config); const previousInclude = includeFromSelectionConfig(state); const currentInclude = [...config.include]; const causes = { localChanged: options.localChanged, remoteChanged: options.remoteChanged, policyChanged }; const causeLines = kind === "first-sync-settings-diverged" ? ["This machine and the remote have different Pi settings on first sync."] : kind === "first-sync-sessions-diverged" ? ["Pi settings match, but local and remote sessions differ on first sync."] : kind === "remote-empty" ? ["The remote storage location is empty."] : [ ...causes.localChanged ? ["Local content changed since the last sync."] : [], ...causes.remoteChanged ? ["Remote content changed since the last sync."] : [], ...causes.policyChanged ? ["Included content changed since the last sync."] : [] ]; const comparison = remote ? formatDiff(local, remote) : formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local); const review = [ `Sync setup: ${safeTerminalText(config.setupName)}`, "", "Why a decision is required:", ...causeLines, ...policyChanged ? [ "", `Previously included: ${safeList(previousInclude)}`, `Currently included: ${safeList(currentInclude)}` ] : [], "", "Observed differences:", ...comparison.split("\n").map(safeTerminalText) ].join("\n"); return new SyncDecisionRequiredError({ kind, setupName: config.setupName, configIdentity: syncConfigReviewIdentity(config), causes, ...policyChanged ? { previousInclude: [...previousInclude] } : {}, currentInclude, review, directions: kind === "remote-empty" ? ["push"] : ["push", "pull"], directMessage: options.directMessage }); } function safeList(values) { return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none"; } // src/sync-operations.ts var STATUS_KEY = "sync"; var VERSION = 1; var DEFAULT_PROFILE = "default"; var POST_LOCAL_COMMIT_TIMEOUT_MS = 3e4; var PublicationStatePersistenceError = class extends Error { head; backupPath; constructor(head, cause, backupPath) { super( `Remote publication ${head.snapshotId} is active, but local sync state could not be saved${backupPath ? `; local backup: ${backupPath}` : ""}: ${errorMessage(cause)}`, { cause } ); this.name = "PublicationStatePersistenceError"; this.head = head; this.backupPath = backupPath; } }; var RollbackPublicationError = class extends Error { backupPath; constructor(backupPath, cause) { super( `Rollback applied locally with backup ${backupPath}, but remote publication failed: ${errorMessage(cause)}`, { cause } ); this.name = "RollbackPublicationError"; this.backupPath = backupPath; } }; function backendFor(config, factory) { return factory(config); } async function status(ctx, options, factory = createSyncBackend) { const config = await loadConfig(options.setup); throwIfAborted(options.signal); ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`); const backend = await backendFor(config, factory); const local = await createSnapshot( config.snapshotIdentity, snapshotOptionsForContext(ctx, config) ); throwIfAborted(options.signal); const state = await readStateForConfig(config); throwIfAborted(options.signal); const head = await backend.readHead(options.signal); throwIfAborted(options.signal); const selectionState = head ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] }) : void 0; const localChanged = hasLocalChanges(local, state, config); const remoteText = head ? `remote: ${head.snapshotId} from ${head.machine} at ${head.createdAt}` : "remote: empty"; const remoteChanged = remoteChangedSinceState( head, state, config, (left, right) => backend.sameRevision(left, right) ); const warnings = syncSessionsWarnings(config); ctx.ui.setStatus(STATUS_KEY, void 0); ctx.ui.notify( [ `sync setup: ${config.setupName}`, `storage connection: ${config.connectionName}`, `storage location: ${safeTerminalText(backend.destination)}`, `publication safety: ${publicationCapabilityDescription(backend.capability)}`, `included content: ${config.include.join(", ") || "none"}`, `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`, remoteText, formatRemoteSelectionStatus(selectionState), `local files: ${local.files.length}`, `local changed since last sync: ${localChanged ? "yes" : "no"}`, `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`, ...warnings ].join("\n"), localChanged || remoteChanged || selectionState?.kind === "different" || warnings.length > 0 ? "warning" : "info" ); } async function diff(ctx, options, factory = createSyncBackend) { const config = await loadConfig(options.setup); throwIfAborted(options.signal); ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`); const backend = await backendFor(config, factory); const local = await createSnapshot( config.snapshotIdentity, snapshotOptionsForContext(ctx, config) ); throwIfAborted(options.signal); const { snapshot: remote, selectionState } = await readRemoteSnapshot( backend, config, options.signal, { allowSelectionDifference: true } ); throwIfAborted(options.signal); ctx.ui.setStatus(STATUS_KEY, void 0); const warnings = syncSessionsWarnings(config); const header = [ `sync setup: ${config.setupName}`, `storage connection: ${config.connectionName}`, `storage location: ${safeTerminalText(backend.destination)}`, `included content: ${config.include.join(", ") || "none"}`, `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`, formatRemoteSelectionStatus(selectionState), ...warnings ].join("\n"); const level = warnings.length > 0 || selectionState?.kind === "different" ? "warning" : "info"; if (!remote) { ctx.ui.notify( `${header} ${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`, level ); return; } ctx.ui.notify(`${header} ${formatDiff(local, remote)}`, level); } async function doctor(ctx, options, factory = createSyncBackend) { const messages = []; let level = "info"; let snapshotOptions = {}; let profile = DEFAULT_PROFILE; let backend; let backendSummary = []; try { const config = await loadConfig(options.setup); throwIfAborted(options.signal); backend = await backendFor(config, factory); profile = config.snapshotIdentity; snapshotOptions = snapshotOptionsForContext(ctx, config); messages.push( `config: ok (sync setup ${config.setupName})`, `included content: ${config.include.join(", ") || "none"}`, `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}` ); backendSummary = [ `storage location: ${safeTerminalText(backend.destination)}`, `publication safety: ${publicationCapabilityDescription(backend.capability)}` ]; const warnings = syncSessionsWarnings(config); if (warnings.length > 0) { level = "warning"; messages.push(...warnings); } } catch (error) { throwIfAborted(options.signal); level = "warning"; messages.push(`config: ${errorMessage(error)}`); } const local = await createSnapshot(profile, snapshotOptions); throwIfAborted(options.signal); const secrets = scanSnapshot(local); if (secrets.length > 0) { level = "warning"; messages.push("secret scan: possible secrets found:"); messages.push(...secrets.map((secret) => `- ${secret}`)); } else { messages.push(`secret scan: ok (${local.files.length} files checked)`); } const lock = await inspectLock(); throwIfAborted(options.signal); if (lock.status === "valid" && isStaleLock(lock.lock)) { level = "warning"; messages.push( `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running` ); } else if (lock.status === "valid") { messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`); } else if (lock.status === "unreadable") { level = "warning"; messages.push( "lock: unreadable; use /sync unlock --stale only after verifying no sync is running" ); } else if (await isLockGuardHeld()) { throwIfAborted(options.signal); level = "warning"; messages.push("lock: guard active while metadata is missing or still being initialized"); } else { messages.push("lock: free"); } if (backend) { messages.push(...backendSummary); const diagnostics = await backend.diagnose(options.signal); throwIfAborted(options.signal); for (const diagnostic of diagnostics) { messages.push(diagnostic.message); if (diagnostic.level !== "info") level = "warning"; } } ctx.ui.notify(messages.join("\n"), level); } async function push(ctx, options, input, factory = createSyncBackend) { const config = input?.config ?? await loadConfig(options.setup); throwIfAborted(options.signal); ctx.ui.setStatus(STATUS_KEY, `pushing ${config.setupName}`); const backend = input?.backend ?? await backendFor(config, factory); const state = input?.state ?? await readStateForConfig(config); throwIfAborted(options.signal); const local = input?.local ?? await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)); throwIfAborted(options.signal); let head = await backend.readHead(options.signal); let remoteForUpload = await readRemoteSnapshotForUpload( backend, config, head, state, options.signal ); if (!options.force && !remoteForUpload && head?.selection && inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind === "different") { remoteForUpload = await readSnapshotForHead(backend, head, options.signal); } if (remoteForUpload && !options.force) { requireCompatibleRemoteSelection(config, remoteForUpload); } if (remoteChangedSinceState( head, state, config, (left, right) => backend.sameRevision(left, right) ) && !options.force) { const remoteForConflict = remoteForUpload ? filterSnapshotForConfigPolicy(remoteForUpload, config) : void 0; if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) { throw createSyncDecision({ kind: head ? "remote-or-policy-changed" : "remote-empty", config, state, local, remote: remoteForConflict, localChanged: hasLocalChanges(local, state, config), remoteChanged: true, directMessage: "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force." }); } } let upload = await snapshotForUpload( backend, config, local, head, remoteForUpload, options.signal ); const secrets = scanSnapshot(local); if (secrets.length > 0) { throw new Error( `Refusing to push possible secrets: ${secrets.map((s) => `- ${s}`).join("\n")}` ); } if (!await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload)) { return "cancelled"; } if (options.force) { const refreshedHead = await backend.readHead(options.signal); if (!sameRemoteHead(backend, head, refreshedHead)) { head = refreshedHead; remoteForUpload = head ? await backend.readSnapshot(head.snapshotRef, options.signal) : void 0; upload = await snapshotForUpload( backend, config, local, head, remoteForUpload, options.signal ); if (!await confirmPush( ctx, options, config, backend, local, upload, head, remoteForUpload, "Remote changed during review. Push the refreshed plan?" )) { return "cancelled"; } } } const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), { signal: options.signal, onCommit: options.onCommit }); try { await writeStateForConfig(config, { version: VERSION, profile: config.snapshotIdentity, lastAppliedSnapshot: result.head.snapshotId, lastRemoteRevision: result.head.revision, lastFileHashes: fileHashMap(local), include: [...config.include] }); } catch (error) { throw new PublicationStatePersistenceError(result.head, error); } if (options.signal?.aborted) return; ctx.ui.setStatus(STATUS_KEY, void 0); if (!options.silent) { ctx.ui.notify( [ `Pushed ${upload.files.length} files from sync setup \u201C${config.setupName}\u201D as ${result.head.snapshotId}.`, ...result.warnings ].filter(Boolean).join("\n"), result.warnings.length > 0 ? "warning" : "info" ); } return "applied"; } async function pull(ctx, options, factory = createSyncBackend) { const config = await loadConfig(options.setup); throwIfAborted(options.signal); ctx.ui.setStatus(STATUS_KEY, `pulling ${config.setupName}`); const backend = await backendFor(config, factory); const state = await readStateForConfig(config); throwIfAborted(options.signal); const local = await createSnapshot( config.snapshotIdentity, snapshotOptionsForContext(ctx, config) ); throwIfAborted(options.signal); const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal); throwIfAborted(options.signal); const localChanged = hasLocalChanges(local, state, config); if (!remote) { throw createSyncDecision({ kind: "remote-empty", config, state, local, localChanged, remoteChanged: false, directMessage: "Remote is empty. Run /sync push from a configured machine first." }); } const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)); if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) { throw createSyncDecision({ kind: "both-changed", config, state, local, remote, localChanged, remoteChanged, directMessage: "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force." }); } if (!options.yes && !await ctx.ui.confirm( snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?", formatPullSummary( config, backend.destination, local, remote, protectedSessionPaths(ctx).size ) )) { ctx.ui.setStatus(STATUS_KEY, void 0); ctx.ui.notify("Pull cancelled.", "info"); return "cancelled"; } throwIfAborted(options.signal); const backup = await backupLocal( config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal ); const applySessionDir = await sessionDirForApply(ctx, remote); throwIfAborted(options.signal); options.onCommit?.(); const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), { include: config.include, sessionDir: applySessionDir }); await writeStateForConfig(config, { version: VERSION, profile: config.snapshotIdentity, lastAppliedSnapshot: remote.id, lastRemoteRevision: head?.revision, lastFileHashes, include: [...config.include] }); if (options.signal?.aborted) return "applied"; ctx.ui.setStatus(STATUS_KEY, void 0); if (!options.silent) { ctx.ui.notify( `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info" ); } else if (options.auto && config.include.includes("sessions") && snapshotIncludesSessions(remote)) { ctx.ui.notify( "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.", "warning" ); } if (options.reload) await maybeReload(ctx, options.signal); return "applied"; } async function syncBoth(ctx, options, factory = createSyncBackend) { const config = await loadConfig(options.setup); throwIfAborted(options.signal); const backend = await backendFor(config, factory); const state = await readStateForConfig(config); throwIfAborted(options.signal); const local = await createSnapshot( config.snapshotIdentity, snapshotOptionsForContext(ctx, config) ); throwIfAborted(options.signal); if (config.include.length === 0) { if (!options.silent) { ctx.ui.notify( `Sync setup \u201C${config.setupName}\u201D includes no files. Choose included content in /sync Settings before syncing.`, "warning" ); } return; } const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal); throwIfAborted(options.signal); const localChanged = hasLocalChanges(local, state, config); const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false; const firstSync = !state.lastAppliedSnapshot; if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) { if (!canPullRemoteSettingsOnFirstSync(local, remote)) { throw createSyncDecision({ kind: "first-sync-settings-diverged", config, state, local, remote, localChanged: true, remoteChanged: true, directMessage: "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push." }); } if (!sameHashes(fileHashMap(local), fileHashMap(remote))) { if (!canPullRemoteSessionsOnFirstSync(local, remote)) { throw createSyncDecision({ kind: "first-sync-sessions-diverged", config, state, local, remote, localChanged: true, remoteChanged: true, directMessage: "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push." }); } await pull(ctx, options, factory); return; } await writeStateForConfig(config, { version: VERSION, profile: config.snapshotIdentity, lastAppliedSnapshot: remote.id, lastRemoteRevision: head?.revision, lastFileHashes: fileHashMap(remote), include: [...config.include] }); if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info"); return; } if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) { await writeStateForConfig(config, { version: VERSION, profile: config.snapshotIdentity, lastAppliedSnapshot: remote.id, lastRemoteRevision: head?.revision, lastFileHashes: fileHashMap(remote), include: [...config.include] }); if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info"); return; } if (localChanged && remoteChanged && state.lastAppliedSnapshot) { throw createSyncDecision({ kind: "both-changed", config, state, local, remote, localChanged, remoteChanged, directMessage: "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force." }); } if (remoteChanged) { await pull(ctx, options, factory); return; } if (localChanged || !remote) { await push(ctx, options, void 0, factory); return; } if (shouldRefreshSyncedState( remote, head, state, config, (left, right) => backend.sameRevision(left, right) )) { await writeStateForConfig(config, { version: VERSION, profile: config.snapshotIdentity, lastAppliedSnapshot: remote.id, lastRemoteRevision: head?.revision, lastFileHashes: fileHashMap(remote), include: [...config.include] }); } if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info"); } async function history(ctx, options, factory = createSyncBackend) { const config = await loadConfig(options.setup); throwIfAborted(options.signal); const backend = await backendFor(config, factory); const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse(); throwIfAborted(options.signal); if (snapshots.length === 0) { ctx.ui.notify("No remote pi-sync history found.", "info"); return; } const currentSnapshot = snapshots[0]?.snapshotId; if (ctx.mode === "tui") { const labels = snapshots.map( (item, index2) => `${index2 + 1}. ${item.createdAt} \xB7 ${safeTerminalText(item.machine)} \xB7 ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " \xB7 sessions" : ""}` ); const selected = await ctx.ui.select( `History for sync setup \u201C${safeTerminalText(config.setupName)}\u201D Choose a snapshot to preview rollback.`, [...labels, "Back"] ); if (!selected || selected === "Back") return; throwIfAborted(options.signal); const index = labels.indexOf(selected); const snapshot = snapshots[index]; if (!snapshot) return; await withLock( "rollback", () => rollback(ctx, { ...options, args: [snapshot.snapshotRef], yes: false }, factory, { backendIdentity: backend.identity, setup: config.setupName }) ); return; } ctx.ui.notify( snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`).join("\n"), "info" ); } async function rollback(ctx, options, factory = createSyncBackend, expectedSelection) { const target = options.args[0]; if (!target) throw new Error("Usage: /sync rollback [--yes]"); const config = await loadConfig(options.setup); throwIfAborted(options.signal); const backend = await backendFor(config, factory); if (expectedSelection && (backend.identity !== expectedSelection.backendIdentity || config.setupName !== expectedSelection.setup)) { throw new Error( "Sync setup or storage location changed while history was open; reopen history and retry." ); } const decoded = await backend.readSnapshot(target, options.signal); const selected = filterSnapshotForConfigPolicy( config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded), config ); const remote = regenerateSnapshotIdentity(selected); const local = await createSnapshot( config.snapshotIdentity, snapshotOptionsForContext(ctx, config) ); const expectedHead = await backend.readHead(options.signal); throwIfAborted(options.signal); if (!options.yes && !await ctx.ui.confirm( snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?", formatRollbackSummary( config, backend.destination, local, remote, target, protectedSessionPaths(ctx).size ) )) { ctx.ui.notify("Rollback cancelled.", "info"); return; } throwIfAborted(options.signal); const backup = await backupLocal( config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal ); const applySessionDir = await sessionDirForApply(ctx, remote); throwIfAborted(options.signal); options.onCommit?.(); const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), { include: config.include, sessionDir: applySessionDir }); let result; try { const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS); const upload = await snapshotForUpload( backend, config, remote, expectedHead, void 0, completionSignal, { ignoreUnreadableRemote: true } ); result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), { signal: completionSignal }); } catch (error) { throw new RollbackPublicationError(backup, error); } try { await writeStateForConfig(config, { version: VERSION, profile: config.snapshotIdentity, lastAppliedSnapshot: result.head.snapshotId, lastRemoteRevision: result.head.revision, lastFileHashes, include: [...config.include] }); } catch (error) { throw new PublicationStatePersistenceError(result.head, error, backup); } if (options.signal?.aborted) return; ctx.ui.notify( [ `Rolled back sync setup \u201C${config.setupName}\u201D to ${target}; latest: ${result.head.snapshotId}. Backup: ${backup}`, ...result.warnings ].filter(Boolean).join("\n"), result.warnings.length > 0 ? "warning" : "info" ); await maybeReload(ctx, options.signal); } function protectedSessionPaths(ctx) { const getSessionFile = ctx.sessionManager.getSessionFile; if (typeof getSessionFile !== "function") return /* @__PURE__ */ new Set(); const sessionFile = getSessionFile.call(ctx.sessionManager); const snapshotPath = sessionFile ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx)) : void 0; return snapshotPath ? /* @__PURE__ */ new Set([snapshotPath]) : /* @__PURE__ */ new Set(); } function snapshotOptionsForContext(ctx, config) { return { include: config.include, sessionDir: sessionDirFromContext(ctx) }; } function sessionDirFromContext(ctx) { const manager = ctx.sessionManager; const usesDefaultSessionDir = manager.usesDefaultSessionDir; if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) { return void 0; } const getSessionDir = manager.getSessionDir; return typeof getSessionDir === "function" ? getSessionDir.call(manager) : void 0; } async function maybeReload(ctx, signal) { if (signal?.aborted || !("reload" in ctx)) return; if (ctx.hasUI && await ctx.ui.confirm( "Reload Pi resources now?", "This reloads extensions, skills, prompts, themes, and context files." )) { if (signal?.aborted) return; await ctx.reload(); } } async function readRemoteSnapshotForUpload(backend, config, head, state, signal) { if (!head || head.snapshotId === state.lastAppliedSnapshot && !syncPolicyChanged(state, config) && (!state.lastRemoteRevision || backend.sameRevision(head.revision, state.lastRemoteRevision))) { return void 0; } return backend.readSnapshot(head.snapshotRef, signal); } async function snapshotForUpload(backend, config, local, head, remote, signal, options = {}) { if (!head) return local; let snapshot = remote; if (!snapshot) { try { snapshot = await backend.readSnapshot(head.snapshotRef, signal); } catch (error) { if (options.ignoreUnreadableRemote) return local; throw error; } } return mergeRemotePreservedFiles(local, snapshot, config); } async function readRemoteSnapshot(backend, config, signal, options = {}) { const head = await backend.readHead(signal); if (!head) return { head: void 0, snapshot: void 0, selectionState: void 0 }; const snapshot = await readSnapshotForHead(backend, head, signal); const selectionState = inspectRemoteSelection(config.include, snapshot); if (!options.allowSelectionDifference && selectionState.kind === "different") { const configIdentity = syncConfigReviewFingerprint(config); throw remoteSelectionMismatch(config, selectionState.include, configIdentity); } return { head, snapshot: filterSnapshotForConfigPolicy(snapshot, config), selectionState }; } async function confirmPush(ctx, options, config, backend, local, upload, head, remote, title = snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?") { throwIfAborted(options.signal); if (options.yes) return true; const confirmed = await ctx.ui.confirm( title, formatPushSummary( config, backend.destination, upload, head, countPreservedRemoteFiles(local, upload), remote ) ); throwIfAborted(options.signal); if (confirmed) return true; ctx.ui.setStatus(STATUS_KEY, void 0); ctx.ui.notify("Push cancelled.", "info"); return false; } function throwIfAborted(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } function sameRemoteHead(backend, left, right) { if (!left || !right) return left === right; return backend.sameRevision(left.revision, right.revision); } async function backupLocal(profile, options = {}, signal) { throwIfAborted(signal); const snapshot = await createSnapshot(profile, options); throwIfAborted(signal); const backupDirectory = path2.join(stateDir(), "backups"); await fs2.mkdir(backupDirectory, { recursive: true }); throwIfAborted(signal); const backupPath = path2.join(backupDirectory, `${snapshot.id}.json.gz`); const encoded = await encodeSnapshot(snapshot); throwIfAborted(signal); await fs2.writeFile(backupPath, encoded, { signal }); return backupPath; } export { PublicationStatePersistenceError, RollbackPublicationError, backupLocal, diff, doctor, history, pull, push, rollback, status, syncBoth }; //# sourceMappingURL=sync-operations-MPAFVVHA.ts.map