// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. import { dispatchManagerResult } from "./chunk-3GRWMWUZ.ts"; import { errorMessage, ownRecord, requiredExistingBucket, requiredInput, runCancellableOperation, safeTerminalText as safeTerminalText2 } from "./chunk-D3S54OXI.ts"; import { addStorageConnection, addSyncSetup, removeStorageConnection, removeSyncSetup, saveNewV3Settings, updateStorageConnection, updateSyncSetup } from "./chunk-4F3W4CSC.ts"; import { syncAttentionMatchesConfig } from "./chunk-JNOOSPEW.ts"; import { SETUP_SWITCH_ACTION_OPTIONS, saveOnSwitch, setupSwitchActionFromLabel, setupSwitchActionLabel, useSyncSetup } from "./chunk-BHJUISB4.ts"; import { setSyncSetupCompletions } from "./chunk-4U6AFLJB.ts"; import "./chunk-FR6CVXAL.ts"; import { inspectLock, isLockGuardHeld, isStaleLock } from "./chunk-LJDSHNS3.ts"; import { safeTerminalText } from "./chunk-W64NEHXT.ts"; import "./chunk-RGJ6SOYR.ts"; import "./chunk-EWX3TPLQ.ts"; import { activeLocalConfigPath, configuredSyncSetupNames, isCloudflareR2Endpoint, loadConfig, loadOnSwitch, loadPartialConfig, localConfigPath, normalizeWebDavPath, normalizeWebDavUrl, readLocalConfigObject, readStateForConfig, syncConfigReviewIdentity, validateWebDavCredentials, validateWebDavNamespace } from "./chunk-APZVBSM6.ts"; import { normalizeGitBranch, normalizeGitDirectory, normalizeGitRemote } from "./chunk-F5QL2GPY.ts"; import { DEFAULT_SYNC_INCLUDE, compareSyncInclude, syncIncludeSelection } from "./chunk-YQ6UW7IF.ts"; // src/manager-ui.ts import { defineMenu as defineMenu4, runMenu as runMenu4 } from "@narumitw/pi-tui-kit"; // src/git-ui.ts async function showGitSetup(ctx, targetName, signal) { const profileName = await requiredInput(ctx, "Name this Git storage connection", "git", signal); if (!profileName) return false; const remoteInput = await requiredInput( ctx, "Git SSH or HTTPS remote", "git@github.com:owner/private-pi-sync.git", signal ); if (!remoteInput) return false; const destination = await promptGitDestination(ctx, targetName, signal); if (!destination) return false; const automatic = await ctx.ui.select( "Automatic sync for this setup", ["Enable automatic sync", "Keep automatic sync off", "Cancel"], { signal } ); throwIfAborted(signal); if (!automatic || automatic === "Cancel") return false; let remote; try { remote = normalizeGitRemote(remoteInput); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return false; } if (!remote) return false; const choice = await ctx.ui.select( [ "Review Git sync setup", "", `Sync setup: ${safeTerminalText2(targetName)}`, `Storage connection: ${safeTerminalText2(profileName)} (Git)`, `Remote: ${safeGitRemote(remote)}`, `Owned branch: ${safeTerminalText2(destination.branch)}`, `Storage location: ${safeTerminalText2(destination.directory)}`, `Included content: ${DEFAULT_SYNC_INCLUDE.length} built-in groups \xB7 Sessions: Off`, `Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}`, "Authentication: existing non-interactive Git/SSH credentials; no credentials are stored by pi-sync.", "The remote repository must already exist. The owned branch may be created on first push." ].join("\n"), ["Save setup", "Cancel"], { signal } ); throwIfAborted(signal); if (choice !== "Save setup") return false; await saveNewV3Settings( { setupName: targetName, connectionName: profileName, connection: { type: "git", remote }, setup: { storage: { connection: profileName, branch: destination.branch, path: destination.directory }, sync: { include: [...DEFAULT_SYNC_INCLUDE], automatic: automatic === "Enable automatic sync" } } }, signal ); if (signal?.aborted) return true; ctx.ui.notify( `Saved Git sync setup \u201C${safeTerminalText2(targetName)}\u201D. Run /sync doctor.`, "info" ); return true; } async function showAddGitStorageProfile(ctx, signal) { const name = await requiredInput(ctx, "Name this Git storage connection", "git", signal); if (!name) return false; const remoteInput = await requiredInput( ctx, "Git SSH or HTTPS remote", "git@github.com:owner/private-pi-sync.git", signal ); if (!remoteInput) return false; let remote; try { remote = normalizeGitRemote(remoteInput); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return false; } if (!remote) return false; const choice = await ctx.ui.select( `Review storage connection Name: ${safeTerminalText2(name)} Type: Git Remote: ${safeGitRemote(remote)} Credentials: existing Git/SSH authentication (not stored) Adding a connection does not contact the remote or start syncing.`, ["Add storage connection", "Cancel"], { signal } ); throwIfAborted(signal); if (choice !== "Add storage connection") return false; await addStorageConnection(name, { type: "git", remote }, signal); if (signal?.aborted) return true; ctx.ui.notify(`Added storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info"); return true; } async function showEditGitStorageProfile(ctx, name, profile, signal, affectedSetups) { const remoteInput = await requiredInput( ctx, "Git SSH or HTTPS remote", typeof profile.remote === "string" ? profile.remote : "git@github.com:owner/private-pi-sync.git", signal ); if (!remoteInput) return false; let remote; try { remote = normalizeGitRemote(remoteInput); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return false; } if (!remote) return false; const choice = await ctx.ui.select( `Review storage connection Storage connection: ${safeTerminalText2(name)} Remote: ${safeGitRemote(String(profile.remote ?? "missing"))} \u2192 ${safeGitRemote(remote)} Affected sync setups: ${affectedSetups && affectedSetups.length > 0 ? affectedSetups.map(safeTerminalText2).join(", ") : "None"} Saving changes future storage access for every affected setup; it does not move or delete remote history.`, ["Save storage connection", "Cancel"], { signal } ); throwIfAborted(signal); if (choice !== "Save storage connection") return false; await updateStorageConnection( name, (current) => { if (current.type !== "git") throw new Error("Storage connection type changed; reopen it."); return { ...current, remote }; }, affectedSetups, signal ); if (signal?.aborted) return true; ctx.ui.notify(`Saved storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info"); return true; } async function showAddGitTarget(ctx, name, profile, signal) { const destination = await promptGitDestination(ctx, name, signal); if (!destination) return false; const preset = await ctx.ui.select( "Choose included content", ["Recommended Pi settings", "Minimal settings", "Cancel"], { signal } ); throwIfAborted(signal); if (!preset || preset === "Cancel") return false; const syncFiles = preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE]; const automatic = await ctx.ui.select( "Automatic sync for this setup", ["Enable automatic sync", "Keep automatic sync off", "Cancel"], { signal } ); throwIfAborted(signal); if (!automatic || automatic === "Cancel") return false; const choice = await ctx.ui.select( `Review Git sync setup Sync setup: ${safeTerminalText2(name)} Storage connection: ${safeTerminalText2(profile)} Owned branch: ${safeTerminalText2(destination.branch)} Storage location: ${safeTerminalText2(destination.directory)} Included content: ${syncFiles.length} built-in groups \xB7 Sessions: Off Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}`, ["Add sync setup", "Cancel"], { signal } ); throwIfAborted(signal); if (choice !== "Add sync setup") return false; await addSyncSetup( name, { storage: { connection: profile, branch: destination.branch, path: destination.directory }, sync: { include: syncFiles, automatic: automatic === "Enable automatic sync" } }, signal ); if (signal?.aborted) return true; ctx.ui.notify(`Added sync setup \u201C${safeTerminalText2(name)}\u201D.`, "info"); return true; } async function showEditGitTarget(ctx, partial, signal) { const targetName = partial.setupName; const destination = await promptGitDestination(ctx, targetName, signal, partial); if (!destination) return false; if (destination.directory !== partial.storagePath && destination.branch === partial.branch) { ctx.ui.notify( "Changing a Git storage path requires a new Git branch so the existing branch remains readable.", "warning" ); return false; } const choice = await ctx.ui.select( `Review sync setup \u201C${safeTerminalText2(targetName)}\u201D Branch: ${safeTerminalText2(partial.branch ?? "pi-sync")} \u2192 ${safeTerminalText2(destination.branch)} Storage path: ${safeTerminalText2(partial.storagePath)} \u2192 ${safeTerminalText2(destination.directory)} Saving changes the future storage location only; it does not move or delete remote history.`, ["Save sync setup", "Cancel"], { signal } ); throwIfAborted(signal); if (choice !== "Save sync setup") return false; await updateSyncSetup( targetName, (setup) => { if (typeof setup.storage.branch !== "string") { throw new Error("Sync setup storage type changed; reopen it."); } return { ...setup, storage: { ...setup.storage, branch: destination.branch, path: destination.directory } }; }, { expectedStorage: partial, signal } ); if (signal?.aborted) return true; ctx.ui.notify(`Saved sync setup \u201C${safeTerminalText2(targetName)}\u201D.`, "info"); return true; } async function promptGitDestination(ctx, targetName, signal, current = {}) { const branchInput = await requiredInput( ctx, "Owned Git branch", current.branch ?? `pi-sync/${targetName}`, signal ); if (!branchInput) return void 0; const pathInput = await requiredInput( ctx, "Git storage path", current.storagePath ?? `pi-sync/${targetName}`, signal ); if (!pathInput) return void 0; try { const branch = normalizeGitBranch(branchInput); const directory = normalizeGitDirectory(pathInput); const namespace = directory.slice(directory.lastIndexOf("/") + 1); return { branch, directory, namespace }; } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return void 0; } } function throwIfAborted(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } function safeGitRemote(remote) { try { const url = new URL(remote); return safeTerminalText2(`${url.protocol}//${url.host}${url.pathname}`); } catch { return safeTerminalText2(remote); } } // src/manager-attention.ts function attentionMainMenuItems(manager) { if (!manager.attention) return []; const disabled = manager.attentionReviewDisabled === true; return [ { id: "review-attention", label: "Review synced content (recommended)", action: "review-attention", ...disabled ? { disabled: true, disabledReason: "Finish or recover the active operation first." } : {} } ]; } function blockedSyncMenuItem(label, manager) { if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return void 0; return { id: "sync", label, description: "Review first.", action: "sync", disabled: true, disabledReason: "Review synced content first." }; } async function showManagerAttention(ctx, attention, runRoute, signal, onSelectionResolved) { const { showRemoteSelectionReview } = await import("./remote-selection-ui-J6OW5DKZ.ts"); if (signal?.aborted) return "close"; const review = await showRemoteSelectionReview( ctx, attention.decision.setupName, signal, void 0, { decision: attention.decision, origin: attention.origin, runRoute, onSelectionResolved } ); if (review.kind === "route-result") { const disposition = await dispatchManagerResult( ctx, review.result, review.route, runRoute, signal, { onSelectionResolved } ); return disposition.kind; } return review.kind === "closed" || review.kind === "stale" ? "close" : "stay"; } // src/manager-recovery.ts import { runConfirmation } from "@narumitw/pi-tui-kit"; // src/operation-availability.ts var DEFAULT_INSPECTION_DEPENDENCIES = { inspectMetadata: inspectLock, inspectGuard: isLockGuardHeld }; async function inspectOperationAvailability(dependencies = DEFAULT_INSPECTION_DEPENDENCIES) { try { const metadata = await dependencies.inspectMetadata(); const guardHeld = await dependencies.inspectGuard(); return classifyOperationAvailability(metadata, guardHeld); } catch (error) { return { kind: "inspection-error", message: errorMessage(error) }; } } function classifyOperationAvailability(metadata, guardHeld) { if (metadata.status === "valid" && !isStaleLock(metadata.lock)) { return { kind: "live", lock: metadata.lock }; } if (guardHeld) { return { kind: "busy", metadata: metadata.status, ...metadata.status === "valid" ? { lock: metadata.lock } : {} }; } if (metadata.status === "valid") { return { kind: "recoverable-stale", lock: metadata.lock }; } if (metadata.status === "unreadable") return { kind: "recoverable-unreadable" }; return { kind: "free" }; } function operationBlocksChanges(availability) { return availability.kind !== "free"; } function operationCanRecover(availability) { return availability.kind === "recoverable-stale" || availability.kind === "recoverable-unreadable"; } // src/manager-recovery.ts async function recoverSyncAccess(ctx, manager, runRoute, sessionSignal, actionSignal) { const operation = manager.operation; if (!operation || !operationCanRecover(operation)) { ctx.ui.notify( "Operation status changed. Refresh the manager before retrying recovery.", "warning" ); return "stay"; } const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal; if (signal.aborted) return "close"; const unreadable = operation.kind === "recoverable-unreadable"; const details = unreadable ? "Pi-sync cannot verify who owns the unreadable lock. Close other Pi sessions that may be syncing before continuing." : `The recorded ${safeTerminalText2(operation.lock.command)} operation (pid ${operation.lock.pid}) appears to have stopped. Close other Pi sessions that may still be syncing before continuing.`; const confirmation = await runConfirmation(ctx, { title: "Restore sync access?", message: [ details, "", "This removes only the local operation lock.", "It does not change settings, local files, sync state, or remote data." ].join("\n"), confirmLabel: "Remove local lock and continue", cancelLabel: "Cancel", signal, isCurrent: () => !signal.aborted, onError: (_currentCtx, error) => { ctx.ui.notify( `Recovery confirmation failed: ${safeTerminalText2(errorMessage(error))}`, "error" ); } }); if (confirmation.kind === "stale") return "close"; if (confirmation.kind === "closed") { if (confirmation.reason === "close") return "close"; if (!signal.aborted) { ctx.ui.notify("Recovery cancelled; the local operation lock was not changed.", "info"); } return "stay"; } if (confirmation.kind !== "confirmed") return "stay"; if (signal.aborted) return "close"; await runRoute(unreadable ? "unlock --stale" : "unlock", signal); if (signal.aborted) return "close"; const latest = await inspectOperationAvailability(); if (signal.aborted) return "close"; return latest.kind === "free" ? "restored" : "stay"; } // src/manager-state.ts import { truncateToWidth } from "@earendil-works/pi-tui"; // src/sync-setups-ui.ts import { defineMenu, runMenu } from "@narumitw/pi-tui-kit"; async function countValidSyncSetups(setups, signal) { let count = 0; for (const name of Object.keys(setups ?? {})) { throwIfAborted2(signal); let valid = false; try { await loadConfig(name); valid = true; } catch { } throwIfAborted2(signal); if (valid) count += 1; } return count; } function throwIfAborted2(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } async function showSyncSetups(ctx, actions, signal) { let selectedName; let exit = false; const nameById = /* @__PURE__ */ new Map(); const menu = defineMenu({ start: "list", screens: { list: ({ state }) => { nameById.clear(); const names = Object.keys(state.setups).sort((left, right) => left.localeCompare(right)); return { kind: "actions", title: "Sync setups", items: [ { id: "add", label: "Add sync setup", action: "add" }, ...names.map((name, index) => { const id = `setup:${index}`; nameById.set(id, name); return { id, label: `${safeTerminalText2(name)}${name === state.active ? " (current)" : ""}`, action: "select" }; }) ], hint: "back" }; }, detail: ({ state }) => ({ kind: "actions", title: state.selected ? `Sync setup \u201C${safeTerminalText2(state.selected.name)}\u201D` : "Sync setup", lines: state.selected?.detail ?? ["This sync setup no longer exists."], items: state.selected ? [ ...!state.selected.name || state.selected.name === state.active || !state.selected.valid ? [] : [ { id: "make-current", label: "Make current\u2026", action: "make-current" } ], { id: "edit", label: "Edit sync setup\u2026", action: "edit" }, ...state.selected.removeUnavailable ? [] : [ { id: "remove", label: "Remove sync setup\u2026", action: "remove" } ], { id: "back", label: "Back", action: "back" } ] : [{ id: "back", label: "Back", action: "back" }], hint: "back" }) }, actions: { add: async () => { try { await actions.add(signal); } catch (error) { if (!signal?.aborted) { ctx.ui.notify( `Sync setup was not added: ${menuErrorMessage(error)} Retry from Add sync setup.`, "error" ); } } return { kind: "stay" }; }, select: async ({ itemId }) => { selectedName = nameById.get(itemId); return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" }; }, "make-current": async () => { if (!selectedName) return { kind: "rejected" }; try { exit = await actions.makeCurrent(selectedName, signal) === "exit"; return exit ? { kind: "close" } : { kind: "stay" }; } catch (error) { notifySetupChangeError(ctx, selectedName, error, signal); return { kind: "stay" }; } }, edit: async () => { if (!selectedName) return { kind: "rejected" }; try { await actions.edit(selectedName, signal); } catch (error) { notifySetupChangeError(ctx, selectedName, error, signal); } return { kind: "stay" }; }, remove: async () => { if (!selectedName) return { kind: "rejected" }; const name = selectedName; try { await actions.remove(name, signal); selectedName = void 0; return { kind: "back" }; } catch (error) { notifySetupChangeError(ctx, name, error, signal); return { kind: "stay" }; } }, back: async () => { selectedName = void 0; return { kind: "back" }; } } }); await runMenu(ctx, menu, { getState: async () => loadSetupMenuState(selectedName, signal), signal, isCurrent: () => !signal?.aborted }); return exit ? "exit" : void 0; } async function loadSetupMenuState(selectedName, signal) { const raw = await readLocalConfigObject(); throwIfAborted2(signal); const setups = ownRecord(raw?.syncSetups) ?? {}; const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : void 0; if (!selectedName || !ownRecord(setups[selectedName])) return { setups, active }; const setupCount = Object.keys(setups).length; const isCurrent = selectedName === active; let detail; let valid = true; try { const config = await loadConfig(selectedName); throwIfAborted2(signal); const selection = syncIncludeSelection(config.include); detail = [ `Status: ${isCurrent ? "Current" : "Not current"}`, `Storage connection: ${safeTerminalText2(config.connectionName)}`, `Endpoint: ${storageEndpoint(config)}`, `Storage location: ${storageLocation(config)}`, `Included content: ${selection.builtIns.length} built-in groups \xB7 ${selection.custom.length} extra paths`, `Sessions: ${selection.sessions ? "On \u2014 privacy-sensitive" : "Off"}`, `Automatic sync: ${config.automatic ? "On" : "Off"}` ]; } catch (error) { valid = false; detail = [ `Status: Invalid${isCurrent ? " current setup" : ""}`, `Reason: ${menuErrorMessage(error)}`, "Make current and sync are unavailable until this setup is repaired." ]; } const removeUnavailable = isCurrent && setupCount > 1; if (removeUnavailable) detail.push("Remove unavailable: switch to another setup first."); return { setups, active, selected: { name: selectedName, detail, valid, removeUnavailable } }; } function notifySetupChangeError(ctx, name, error, signal) { if (signal?.aborted) return; ctx.ui.notify( `Sync setup \u201C${safeTerminalText2(name)}\u201D was not changed: ${menuErrorMessage(error)} Reopen it and retry.`, "error" ); } function menuErrorMessage(error) { return safeTerminalText2(errorMessage(error)); } function storageEndpoint(config) { switch (config.backend.type) { case "s3": return safeTerminalText2(config.backend.profile.endpoint); case "git": return safeTerminalText2(config.backend.profile.remote); case "webdav": return safeTerminalText2(config.backend.profile.url); } } function storageLocation(config) { switch (config.backend.type) { case "s3": return safeTerminalText2(`${config.backend.destination.bucket}/${config.storagePath}`); case "git": return safeTerminalText2(`Git \xB7 ${config.backend.destination.branch}:${config.storagePath}`); case "webdav": return safeTerminalText2(`WebDAV \xB7 ${config.storagePath}`); } } // src/manager-state.ts var MAIN_MENU_ACTIONS = [ "Sync now (recommended)", "Switch sync setup", "Status & changes", "Settings", "More\u2026" ]; async function describeManagerState(signal, attention, inspectOperation = inspectOperationAvailability) { let raw; try { raw = await readLocalConfigObject(); } catch (error) { return { title: [ "Manage sync", "", "Settings file needs repair. Automatic sync and settings writes are paused.", `Error: ${safeTerminalText2(errorMessage(error))}`, `File: ${safeTerminalText2(await activeLocalConfigPath())}`, "", "Repair the JSON file, then reopen /sync." ].join("\n"), actions: ["Help"] }; } if (!raw) { return { title: ["Manage sync", "", "Not set up.", "", "What do you want to do?"].join("\n"), actions: ["Set up sync", "Help"] }; } const configuredTargets = ownRecord(raw.syncSetups); if (raw.version === 3 && configuredTargets && Object.keys(configuredTargets).length === 0) { return { title: [ "Manage sync", "", "No sync setups are configured.", "Add a sync setup using an existing storage connection.", "", "What do you want to do?" ].join("\n"), actions: ["Sync setups\u2026", "Storage connections\u2026", "Help"] }; } try { const config = await loadConfig(); const operation = await inspectOperation(); const changesBlocked = operationBlocksChanges(operation); const selection = syncIncludeSelection(config.include); let currentAttention; if (attention) { try { const attentionConfig = attention.decision.setupName === config.setupName ? config : await loadConfig(attention.decision.setupName); if (syncAttentionMatchesConfig(attention, attentionConfig)) currentAttention = attention; } catch { currentAttention = void 0; } if (signal?.aborted) throw signal.reason; } const attentionComparison = currentAttention ? compareSyncInclude( currentAttention.decision.localInclude, currentAttention.decision.remoteInclude ) : void 0; const noSyncedContent = config.include.length === 0; const syncState = changesBlocked ? void 0 : await readStateForConfig(config).catch(() => void 0); const lastAppliedSnapshot = changesBlocked ? "Unavailable while operations are locked" : syncState?.lastAppliedSnapshot ? safeTerminalText2(syncState.lastAppliedSnapshot) : syncState ? "Never synced" : "Unavailable"; const canSwitch = await countValidSyncSetups(configuredTargets, signal) > 1; const mainActions = MAIN_MENU_ACTIONS.filter( (action) => action !== "Switch sync setup" || canSwitch ); const ordinaryTitle = [ "Manage sync", "", `Current sync setup: ${safeTerminalText2(config.setupName)}`, `Storage: ${backendStorageDescription(config)}`, `Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} \xB7 ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} \xB7 Sessions ${selection.sessions ? "on" : "off"}`, `Automatic sync: ${config.automatic ? "On" : "Off"}`, `Last applied: ${lastAppliedSnapshot}`, ...currentAttention ? [ currentAttention.decision.setupName === config.setupName ? "Sync status: Review needed" : `Sync status: Review needed for setup ${safeTerminalText2(currentAttention.decision.setupName)}`, attentionComparison?.remoteOnly.length === 0 && attentionComparison.localOnly.length === 0 ? "Only the synced-content order differs." : `Remote-only paths: ${attentionComparison?.remoteOnly.length ?? 0} \xB7 Device-only paths: ${attentionComparison?.localOnly.length ?? 0}`, "Nothing has been changed." ] : ["Remote status: Not checked"], ...noSyncedContent ? [ "", "No included content is selected. Choose included content in Settings before syncing." ] : [], "", "What do you want to do?" ]; return { title: (changesBlocked ? ["Manage sync", ...operationStatusLines(operation)] : ordinaryTitle).join("\n"), actions: operationActions(operation, noSyncedContent, canSwitch, mainActions), operation, ...currentAttention ? { attention: currentAttention, attentionBlocksSync: currentAttention.decision.setupName === config.setupName, attentionReviewDisabled: changesBlocked } : {} }; } catch (error) { if (signal?.aborted) throw error; return { title: [ "Manage sync", "", "Settings need attention. Automatic sync is paused.", `Current sync setup: ${safeTerminalText2(typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : "none")}`, `Error: ${safeTerminalText2(errorMessage(error))}`, `File: ${safeTerminalText2(await activeLocalConfigPath())}`, "", "What do you want to do?" ].join("\n"), actions: ["Sync setups\u2026", "Storage connections\u2026", "History & recovery\u2026", "Help"] }; } } function operationActions(operation, noSyncedContent, canSwitch, mainActions) { if (operationCanRecover(operation)) { return [ "Restore sync access\u2026 (recommended)", "Status & changes", "History & recovery\u2026", "Help" ]; } if (operation.kind !== "free") { return ["Refresh operation status", "Status & changes", "History & recovery\u2026", "Help"]; } return noSyncedContent ? ["Settings", ...canSwitch ? ["Switch sync setup"] : [], "Status & changes", "More\u2026"] : mainActions; } function operationStatusLines(operation) { switch (operation.kind) { case "free": return []; case "live": { const command = truncateToWidth(safeTerminalText2(operation.lock.command), 16, "\u2026"); return [ `Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Settings and More return.` ]; } case "busy": return [ "Pi-sync may be starting or finishing. Wait, then refresh; Settings and More remain unavailable." ]; case "recoverable-stale": return [ "Sync paused: old lock remains. Close other Pi sessions then restore; Settings and More return." ]; case "recoverable-unreadable": return [ "Sync paused: owner unknown. Close other Pi sessions then restore; Settings and More return." ]; case "inspection-error": return [ "Lock check failed. Fix path access, then refresh; Settings and More remain unavailable." ]; } } function backendStorageDescription(config) { const connection = safeTerminalText2(config.connectionName); switch (config.backend.type) { case "s3": { const type = config.backend.profile.kind === "r2" || isCloudflareR2Endpoint(config.backend.profile.endpoint) ? "Cloudflare R2" : "S3-compatible"; return `${type} \xB7 ${connection} \xB7 ${safeTerminalText2(config.backend.destination.bucket)}`; } case "webdav": return `WebDAV \xB7 ${connection} \xB7 ${safeTerminalText2(config.backend.destination.path)}`; case "git": return `Git \xB7 ${connection} \xB7 ${safeTerminalText2(config.backend.destination.branch)}`; } } // src/secret-input.ts import { CURSOR_MARKER, decodeKittyPrintable, Text, truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui"; import { runCustomInteraction } from "@narumitw/pi-tui-kit"; var MASK = "\u2022"; async function promptSecret(ctx, title, options = { required: true }) { if (ctx.mode !== "tui" || options.signal?.aborted) return void 0; const result = await runCustomInteraction(ctx, { signal: options.signal, isCurrent: () => !options.signal?.aborted, create: ({ tui, theme, keybindings, complete }) => { const heading = new Text("", 0, 0); const hint = new Text("", 0, 0); const submitKey = keybindingText(keybindings, "tui.input.submit", "enter"); const cancelKey = keybindingText(keybindings, "tui.select.cancel", "esc"); const applyTheme = () => { heading.setText(theme.fg("accent", theme.bold(title))); hint.setText(theme.fg("dim", `${submitKey} save \u2022 ${cancelKey} cancel \u2022 Input is hidden`)); }; applyTheme(); const input = new MaskedInput(keybindings); const component = { get focused() { return input.focused; }, set focused(focused) { input.focused = focused; }, render(width) { const safeWidth = Math.max(1, width); return [ ...heading.render(safeWidth), ...input.render(safeWidth), ...hint.render(safeWidth) ].map((line) => truncateToWidth2(line, safeWidth)); }, invalidate() { applyTheme(); heading.invalidate(); input.invalidate(); hint.invalidate(); }, handleInput(data) { if (keybindings.matches(data, "tui.select.cancel")) complete(void 0); else if (keybindings.matches(data, "tui.input.submit")) complete(input.getValue()); else input.handleInput(data); tui.requestRender(); }, dispose() { input.clear(); } }; return component; } }); if (result.kind === "error") throw result.error; if (result.kind !== "completed" || result.value === void 0) return void 0; const value = result.value; if (options.required !== false && value.length === 0) { ctx.ui.notify(`${title} is required.`, "warning"); return void 0; } return value; } var MaskedInput = class { constructor(keybindings) { this.keybindings = keybindings; } keybindings; focused = false; value = []; cursor = 0; paste = ""; pasting = false; getValue() { return this.value.join(""); } handleInput(data) { if (data.includes("\x1B[200~")) { this.pasting = true; this.paste = ""; data = data.replace("\x1B[200~", ""); } if (this.pasting) { this.paste += data; const end = this.paste.indexOf("\x1B[201~"); if (end < 0) return; const pasted = this.paste.slice(0, end).replace(/[\r\n]/gu, "").replace(/\t/gu, " "); this.insert(pasted); const remaining = this.paste.slice(end + 6); this.paste = ""; this.pasting = false; if (remaining) this.handleInput(remaining); return; } if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) { if (this.cursor > 0) this.value.splice(--this.cursor, 1); return; } if (this.keybindings.matches(data, "tui.editor.deleteCharForward")) { if (this.cursor < this.value.length) this.value.splice(this.cursor, 1); return; } if (this.keybindings.matches(data, "tui.editor.cursorLeft")) { this.cursor = Math.max(0, this.cursor - 1); return; } if (this.keybindings.matches(data, "tui.editor.cursorRight")) { this.cursor = Math.min(this.value.length, this.cursor + 1); return; } if (this.keybindings.matches(data, "tui.editor.cursorLineStart")) { this.cursor = 0; return; } if (this.keybindings.matches(data, "tui.editor.cursorLineEnd")) { this.cursor = this.value.length; return; } if (this.keybindings.matches(data, "tui.editor.deleteToLineStart")) { this.value.splice(0, this.cursor); this.cursor = 0; return; } if (this.keybindings.matches(data, "tui.editor.deleteToLineEnd")) { this.value.splice(this.cursor); return; } const printable = decodeKittyPrintable(data) ?? data; if (!hasControlCharacter(printable)) this.insert(printable); } render(width) { const prompt = "> "; const available = width - prompt.length; if (available <= 0) return [truncateToWidth2(prompt, Math.max(1, width))]; const contentWidth = Math.max(0, available - 1); let start = 0; if (this.value.length > contentWidth) { start = Math.max( 0, Math.min(this.cursor - Math.floor(contentWidth / 2), this.value.length - contentWidth) ); } const end = Math.min(this.value.length, start + contentWidth); const visibleCursor = Math.max(0, Math.min(this.cursor - start, end - start)); const masks = Array.from({ length: end - start }, () => MASK); const before = masks.slice(0, visibleCursor).join(""); const atCursor = visibleCursor < masks.length ? MASK : " "; const after = masks.slice(visibleCursor + (visibleCursor < masks.length ? 1 : 0)).join(""); const marker = this.focused ? CURSOR_MARKER : ""; const line = `${prompt}${before}${marker}\x1B[7m${atCursor}\x1B[27m${after}`; return [truncateToWidth2(line, width, "")]; } invalidate() { } clear() { this.value.fill(""); this.value = []; this.paste = ""; this.cursor = 0; this.pasting = false; } insert(value) { const characters = Array.from(value); this.value.splice(this.cursor, 0, ...characters); this.cursor += characters.length; } }; function keybindingText(keybindings, binding, fallback) { const keys = keybindings.getKeys(binding).map(String).map((key) => { if (key === "return") return "enter"; if (key === "escape") return "esc"; return hasControlCharacter(key) ? "" : key; }).filter(Boolean); return keys.join("/") || fallback; } function hasControlCharacter(value) { return [...value].some((character) => { const code = character.codePointAt(0) ?? 0; return code < 32 || code >= 127 && code <= 159; }); } // src/s3-credentials-ui.ts async function chooseS3CredentialUpdate(ctx, profile, signal) { const hasStored = typeof profile.accessKeyId === "string" && typeof profile.secretAccessKey === "string"; if (hasStored) { const action = await ctx.ui.select( "Credentials", ["Keep current credentials", "Change credential source", "Cancel"], { signal } ); throwIfAborted3(signal); if (!action || action === "Cancel") return void 0; if (action === "Keep current credentials") { return { profileFields: {}, summary: "Unchanged (values hidden)", ready: true }; } } const selected = await chooseS3Credentials(ctx, signal); return selected ? { ...selected, replace: true } : void 0; } function applyS3CredentialUpdate(profile, credentials) { const next = { ...profile }; if (credentials.replace) { delete next.accessKeyId; delete next.secretAccessKey; delete next.sessionToken; } return { ...next, ...credentials.profileFields }; } async function chooseS3Credentials(ctx, signal) { const choice = await ctx.ui.select( "Credentials\n\nCredentials are stored in the private pi-sync settings file. Secret values are masked during input and never shown afterward.", ["Store credentials privately", "Cancel"], { signal } ); throwIfAborted3(signal); if (choice !== "Store credentials privately") return void 0; const accessKeyId = await requiredCredentialInput(ctx, "Access key ID", "access-key-id", signal); if (!accessKeyId) return void 0; const secretAccessKey = await promptSecret(ctx, "Secret access key", { signal }); throwIfAborted3(signal); if (secretAccessKey === void 0) return void 0; return { profileFields: { accessKeyId, secretAccessKey }, summary: "Stored privately (values hidden)", ready: true }; } async function requiredCredentialInput(ctx, title, placeholder, signal) { const value = await ctx.ui.input(title, placeholder, { signal }); throwIfAborted3(signal); if (value === void 0) return void 0; const normalized = value.trim(); if (!normalized) { ctx.ui.notify(`${title} is required.`, "warning"); return void 0; } return normalized.includes("<") || normalized.includes(">") ? void 0 : normalized; } function throwIfAborted3(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } // src/settings-ui.ts import { defineMenu as defineMenu2, runMenu as runMenu2 } from "@narumitw/pi-tui-kit"; async function showSyncSettings(ctx, runRoute, signal) { if (ctx.mode !== "tui") { ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info"); return; } const initial = await loadConfig(); if (signal?.aborted) return; const setupName = initial.setupName; const menu = defineMenu2({ start: "settings", screens: { settings: ({ state }) => ({ kind: "settings", title: "Pi Sync Settings", lines: [ `Sync setup: ${safeTerminalText(state.setupName)} \xB7 Storage connection: ${safeTerminalText(state.connectionName)}` ], items: [ { id: "automatic", label: "Automatic sync", description: "Run conservative synchronization at session startup and shutdown.", currentValue: state.automatic ? "On" : "Off", values: ["On", "Off"], action: "automatic" }, { id: "onSwitch", label: "After switching setup", description: "Ask before a reviewed pull, start a reviewed pull, or switch without checking remote files.", currentValue: setupSwitchActionLabel(state.onSwitch), values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label), action: "on-switch" }, { id: "include", label: "Included content", description: `${state.include.length} selected path${state.include.length === 1 ? "" : "s"}. Opens the reviewed content-selection draft.`, currentValue: "Open editor", action: "include" }, { id: "remoteInclude", label: "Compare synced content", description: "Review this device and remote content lists before choosing either one.", currentValue: "Review", action: "remote-include" } ] }) }, actions: { automatic: async ({ value, signal: actionSignal }) => { const automatic = value === "On"; const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal; try { const latest = await loadConfig(setupName); if (mutationSignal.aborted) return { kind: "rejected" }; if (latest.automatic === automatic) return { kind: "stay" }; await updateSyncSetup( setupName, (setup) => ({ ...setup, sync: { ...setup.sync, automatic } }), { signal: mutationSignal } ); if (mutationSignal.aborted) return { kind: "rejected" }; ctx.ui.notify( `Automatic sync ${automatic ? "enabled" : "disabled"} for \u201C${safeTerminalText(setupName)}\u201D.`, "info" ); return { kind: "stay" }; } catch (error) { if (!mutationSignal.aborted) notifySaveFailure(ctx, error); return { kind: "rejected" }; } }, "on-switch": async ({ value, signal: actionSignal }) => { const action = value ? setupSwitchActionFromLabel(value) : void 0; if (!action) return { kind: "rejected" }; const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal; try { const latest = await loadConfig(setupName); if (mutationSignal.aborted) return { kind: "rejected" }; if (latest.onSwitch === action) return { kind: "stay" }; await saveOnSwitch(action, mutationSignal); if (mutationSignal.aborted) return { kind: "rejected" }; ctx.ui.notify(`After switching setup: ${value}.`, "info"); return { kind: "stay" }; } catch (error) { if (!mutationSignal.aborted) notifySaveFailure(ctx, error); return { kind: "rejected" }; } }, include: async ({ signal: actionSignal }) => { const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal; await runRoute("files", editorSignal, void 0, setupName); return editorSignal.aborted ? { kind: "rejected" } : { kind: "stay" }; }, "remote-include": async ({ signal: actionSignal }) => { const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal; const { showRemoteSelectionReview } = await import("./remote-selection-ui-J6OW5DKZ.ts"); if (reviewSignal.aborted) return { kind: "rejected" }; const review = await showRemoteSelectionReview(ctx, setupName, reviewSignal, void 0, { origin: "settings", runRoute }); if (reviewSignal.aborted) return { kind: "rejected" }; if (review.kind === "route-result") { const disposition = await dispatchManagerResult( ctx, review.result, review.route, runRoute, reviewSignal ); return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" }; } return review.kind === "closed" || review.kind === "stale" ? { kind: "close" } : { kind: "stay" }; } } }); await runMenu2(ctx, menu, { getState: () => loadConfig(setupName), signal, isCurrent: () => !signal?.aborted }); } function notifySaveFailure(ctx, error) { ctx.ui.notify( `Pi Sync settings save failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); } // src/storage-connections-ui.ts import { defineMenu as defineMenu3, runMenu as runMenu3 } from "@narumitw/pi-tui-kit"; // src/webdav-ui.ts async function showWebDavSetup(ctx, targetName, signal) { const url = await requiredInput2( ctx, "WebDAV collection URL", "https://cloud.example.com/remote.php/dav/files/user", signal ); if (!url) return false; const username = await requiredInput2(ctx, "WebDAV username", "user", signal); if (!username) return false; const password = await awaitActive(signal, promptSecret(ctx, "WebDAV password", { signal })); if (password === void 0) return false; const location = await chooseDestination(ctx, targetName, signal); if (!location) return false; const connection = validateConnection(ctx, url, username, password); const destination = validateDestination(ctx, location.path); if (!connection || !destination) return false; const content = await chooseContent(ctx, signal); if (!content) return false; const automatic = await select( ctx, "Automatic sync for this setup", ["Enable automatic sync", "Keep automatic sync off", "Cancel"], signal ); if (!automatic || automatic === "Cancel") return false; const sessions = await chooseSessions(ctx, signal); if (sessions === void 0) return false; const profileName = "webdav"; const review = await select( ctx, [ "Review WebDAV setup", "", `Sync setup: ${safe(targetName)}`, `Storage connection: ${profileName} (WebDAV)`, `URL: ${displayUrl(connection.url)}`, `Storage location: ${safe(destination.path)}`, "Username: stored in the private settings file (value hidden)", "Password: configured (value hidden)", `Conditional writes: /sync doctor verifies atomic If-Match and If-None-Match support before publication.`, `Included content: ${content.length} built-in groups \xB7 Sessions: ${sessions ? "On \u2014 privacy warning acknowledged" : "Off"}`, `Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}` ].join("\n"), ["Save setup", "Cancel"], signal ); if (review !== "Save setup") return false; throwIfAborted4(signal); await awaitActive( signal, saveNewV3Settings( { setupName: targetName, connectionName: profileName, connection: { type: "webdav", url: connection.url, credentials: { username: connection.username, password: connection.password ?? "" } }, setup: { storage: { connection: profileName, path: destination.path }, sync: { include: [...content, ...sessions ? ["sessions"] : []], automatic: automatic === "Enable automatic sync" } } }, signal ) ); ctx.ui.notify(`Sync setup \u201C${safe(targetName)}\u201D is ready. Use Sync now when ready.`, "info"); return true; } async function showAddWebDavTarget(ctx, name, profile, signal) { const location = await chooseDestination(ctx, name, signal); if (!location) return false; const destination = validateDestination(ctx, location.path); if (!destination) return false; const content = await chooseContent(ctx, signal); if (!content) return false; const review = await select( ctx, `Review WebDAV sync setup Sync setup: ${safe(name)} Storage connection: ${safe(profile)} Storage location: ${safe(destination.path)} Included content: ${content.length} built-in groups \xB7 Sessions: Off Adding this setup does not sync or modify remote data.`, ["Add sync setup", "Cancel"], signal ); if (review !== "Add sync setup") return false; throwIfAborted4(signal); await awaitActive( signal, addSyncSetup( name, { storage: { connection: profile, path: destination.path }, sync: { include: content, automatic: true } }, signal ) ); ctx.ui.notify(`Added sync setup \u201C${safe(name)}\u201D.`, "info"); return true; } async function showEditWebDavTarget(ctx, partial, signal) { const remotePath = await requiredInput2(ctx, "WebDAV storage path", partial.storagePath, signal); if (!remotePath) return false; const destination = validateDestination(ctx, remotePath); if (!destination) return false; const review = await select( ctx, `Review sync setup \u201C${safe(partial.setupName)}\u201D Storage path: ${safe(partial.storagePath)} \u2192 ${safe(destination.path)} Saving changes the future storage location only; it does not move or delete remote data.`, ["Save sync setup", "Cancel"], signal ); if (review !== "Save sync setup") return false; throwIfAborted4(signal); await awaitActive( signal, updateSyncSetup( partial.setupName, (setup) => ({ ...setup, storage: { ...setup.storage, path: destination.path } }), { expectedStorage: partial, signal } ) ); ctx.ui.notify(`Saved sync setup \u201C${safe(partial.setupName)}\u201D.`, "info"); return true; } async function showAddWebDavStorageProfile(ctx, signal) { const name = await requiredInput2(ctx, "Name this storage connection", "webdav", signal); if (!name) return false; const url = await requiredInput2( ctx, "WebDAV collection URL", "https://cloud.example.com/dav", signal ); if (!url) return false; const username = await requiredInput2(ctx, "WebDAV username", "user", signal); if (!username) return false; const password = await awaitActive(signal, promptSecret(ctx, "WebDAV password", { signal })); if (password === void 0) return false; const connection = validateConnection(ctx, url, username, password); if (!connection) return false; const review = await select( ctx, `Review storage connection Name: ${safe(name)} Type: WebDAV URL: ${displayUrl(connection.url)} Username: stored privately (value hidden) Password: configured (value hidden) Adding a connection does not contact the server or start syncing.`, ["Add storage connection", "Cancel"], signal ); if (review !== "Add storage connection") return false; throwIfAborted4(signal); await awaitActive( signal, addStorageConnection( name, { type: "webdav", url: connection.url, credentials: { username: connection.username, password: connection.password ?? "" } }, signal ) ); ctx.ui.notify(`Added storage connection \u201C${safe(name)}\u201D.`, "info"); return true; } async function showEditWebDavStorageProfile(ctx, name, profile, signal, affectedSetups) { const url = await requiredInput2( ctx, "WebDAV collection URL", String(profile.url ?? "https://cloud.example.com/dav"), signal ); if (!url) return false; const username = await requiredInput2( ctx, "WebDAV username", String(profile.username ?? "user"), signal ); if (!username) return false; let password; let replacePassword = false; if (typeof profile.password === "string" && profile.password.length > 0) { const passwordAction = await select( ctx, "WebDAV password", ["Keep current password", "Replace password", "Cancel"], signal ); if (!passwordAction || passwordAction === "Cancel") return false; replacePassword = passwordAction === "Replace password"; } else { replacePassword = true; } if (replacePassword) { password = await awaitActive(signal, promptSecret(ctx, "New WebDAV password", { signal })); if (password === void 0) return false; } const connection = validateConnection(ctx, url, username, password); if (!connection) return false; const review = await select( ctx, `Review storage connection Storage connection: ${safe(name)} URL: ${displayUrl(String(profile.url ?? "https://invalid.invalid"))} \u2192 ${displayUrl(connection.url)} Username: stored privately (value hidden) Password: ${replacePassword ? "will be replaced" : "unchanged"} (value hidden) Affected sync setups: ${affectedSetups && affectedSetups.length > 0 ? affectedSetups.map(safe).join(", ") : "None"} Saving changes future storage access for every affected setup; it does not move remote data.`, ["Save storage connection", "Cancel"], signal ); if (review !== "Save storage connection") return false; throwIfAborted4(signal); await awaitActive( signal, updateStorageConnection( name, (current) => { if (current.type !== "webdav") { throw new Error("Storage connection type changed; reopen it."); } return { ...current, url: connection.url, credentials: { ...current.credentials, username: connection.username, password: replacePassword ? connection.password ?? current.credentials.password : current.credentials.password } }; }, affectedSetups, signal ) ); ctx.ui.notify(`Saved storage connection \u201C${safe(name)}\u201D.`, "info"); return true; } async function chooseDestination(ctx, targetName, signal) { const remotePath = await requiredInput2( ctx, "WebDAV storage path", `pi-sync/${targetName}`, signal ); return remotePath ? validateDestination(ctx, remotePath) : void 0; } async function chooseContent(ctx, signal) { const choice = await select( ctx, "Choose an initial sync preset", ["Recommended Pi settings", "Minimal settings", "Cancel"], signal ); if (!choice || choice === "Cancel") return void 0; return choice === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE]; } async function chooseSessions(ctx, signal) { const choice = await select( ctx, "Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.", ["Keep sessions off (recommended)", "Include session conversations", "Cancel"], signal ); if (!choice || choice === "Cancel") return void 0; if (choice !== "Include session conversations") return false; return confirm( ctx, "Include session conversations?", "I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.", signal ); } async function requiredInput2(ctx, title, placeholder, signal) { const value = await awaitActive(signal, ctx.ui.input(title, placeholder, { signal })); if (value === void 0) return void 0; const trimmed = value.trim(); if (!trimmed) { ctx.ui.notify(`${title} is required.`, "warning"); return void 0; } return trimmed; } async function select(ctx, title, options, signal) { return awaitActive(signal, ctx.ui.select(title, options, { signal })); } async function confirm(ctx, title, message, signal) { return awaitActive(signal, ctx.ui.confirm(title, message, { signal })); } async function awaitActive(signal, operation) { const result = await operation; throwIfAborted4(signal); return result; } function throwIfAborted4(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } function validateConnection(ctx, url, username, password) { try { const normalizedUrl = normalizeWebDavUrl(url); if (!normalizedUrl) throw new Error("WebDAV URL is required."); validateWebDavCredentials(username, password); return { url: normalizedUrl, username: username.trim(), ...password === void 0 ? {} : { password } }; } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return void 0; } } function validateDestination(ctx, path, namespace) { try { const basePath = normalizeWebDavPath(path); const normalizedPath = namespace ? normalizeWebDavPath(`${basePath}/${namespace.trim()}`) : basePath; const resolvedNamespace = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); validateWebDavNamespace(resolvedNamespace); return { path: normalizedPath, namespace: resolvedNamespace }; } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return void 0; } } function displayUrl(value) { try { return `${new URL(value).origin}/\u2026`; } catch { return "invalid URL (value hidden)"; } } function safe(value) { return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "\uFFFD"); } // src/storage-connections-ui.ts async function showStorageConnections(ctx, signal) { let selectedName; const nameById = /* @__PURE__ */ new Map(); const menu = defineMenu3({ start: "list", screens: { list: ({ state }) => { nameById.clear(); const names = Object.keys(state.profiles).sort((left, right) => left.localeCompare(right)); return { kind: "actions", title: "Storage connections", lines: state.version3 ? [] : ["Create version 3 settings before managing storage connections."], items: state.version3 ? [ { id: "add", label: "Add storage connection", action: "add" }, ...names.map((name, index) => { const id = `connection:${index}`; nameById.set(id, name); return { id, label: safeTerminalText2(name), action: "select" }; }) ] : [], hint: "back" }; }, detail: ({ state }) => ({ kind: "actions", title: state.selected ? `Storage connection \u201C${safeTerminalText2(state.selected.name)}\u201D` : "Storage connection", lines: state.selected?.lines ?? ["This storage connection no longer exists."], items: state.selected ? [ { id: "edit", label: "Edit storage connection\u2026", action: "edit" }, ...state.selected.usedBy.length === 0 ? [ { id: "remove", label: "Remove storage connection\u2026", action: "remove" } ] : [], { id: "back", label: "Back", action: "back" } ] : [{ id: "back", label: "Back", action: "back" }], hint: "back" }) }, actions: { add: async () => { try { await showAddStorageConnection(ctx, signal); } catch (error) { if (!signal?.aborted) { ctx.ui.notify( `Storage connection was not added: ${safeTerminalText2(errorMessage(error))} Retry from Add storage connection.`, "error" ); } } return { kind: "stay" }; }, select: async ({ itemId }) => { selectedName = nameById.get(itemId); return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" }; }, edit: async ({ state }) => { if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" }; try { await editStorageConnection( ctx, state.selected.name, state.selected.profile, state.selected.usedBy, signal ); } catch (error) { notifyConnectionError(ctx, state.selected.name, error, signal); } return { kind: "stay" }; }, remove: async ({ state }) => { if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" }; const name = state.selected.name; const confirmed = await ctx.ui.confirm( "Remove storage connection?", `Remove local storage connection \u201C${safeTerminalText2(name)}\u201D? Remote data and history are not deleted.`, { signal } ); if (!confirmed || signal?.aborted) return { kind: "rejected" }; try { await removeStorageConnection(name, signal); ctx.ui.notify(`Removed storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info"); selectedName = void 0; return { kind: "back" }; } catch (error) { notifyConnectionError(ctx, name, error, signal); return { kind: "stay" }; } }, back: async () => { selectedName = void 0; return { kind: "back" }; } } }); await runMenu3(ctx, menu, { getState: () => loadStorageMenuState(selectedName, signal), signal, isCurrent: () => !signal?.aborted }); } async function loadStorageMenuState(selectedName, signal) { const raw = await readLocalConfigObject(); if (signal?.aborted) throw signal.reason; const profiles = ownRecord(raw?.storageConnections) ?? {}; const profile = selectedName ? ownRecord(profiles[selectedName]) : void 0; if (!selectedName || !profile) { return { version3: raw?.version === 3, profiles, selected: void 0 }; } const usedBy = referencingSetups(raw, selectedName); return { version3: raw?.version === 3, profiles, selected: { name: selectedName, profile, usedBy, lines: [ `Type: ${connectionType(profile)}`, `Endpoint: ${connectionEndpoint(profile)}`, `Credentials: ${credentialSource(profile)}`, `Used by: ${usedBy.length > 0 ? usedBy.map(safeTerminalText2).join(", ") : "No sync setups"}`, ...usedBy.length > 0 ? ["Remove unavailable: edit or remove the listed sync setups first."] : [] ] } }; } function notifyConnectionError(ctx, name, error, signal) { if (signal?.aborted) return; ctx.ui.notify( `Storage connection \u201C${safeTerminalText2(name)}\u201D was not changed: ${safeTerminalText2(errorMessage(error))} Reopen it and retry.`, "error" ); } async function editStorageConnection(ctx, name, profile, usedBy, signal) { if (ctx.mode !== "tui") { ctx.ui.notify( "Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.", "warning" ); return; } if (profile.type === "webdav") { await showEditWebDavStorageProfile( ctx, name, { ...profile, kind: "webdav", ...ownRecord(profile.credentials) ?? {} }, signal, usedBy ); return; } if (profile.type === "git") { await showEditGitStorageProfile(ctx, name, { ...profile, kind: "git" }, signal, usedBy); return; } const endpoint = await requiredInput( ctx, "Endpoint", String(profile.endpoint ?? "https://s3.example.com"), signal ); if (!endpoint || signal?.aborted) return; const region = await requiredInput(ctx, "Region", String(profile.region ?? "auto"), signal); if (!region || signal?.aborted) return; const storedCredentials = ownRecord(profile.credentials) ?? {}; const credentials = await chooseS3CredentialUpdate( ctx, { ...profile, ...storedCredentials }, signal ); if (!credentials || signal?.aborted) return; const save = await ctx.ui.select( [ "Review storage connection", "", `Storage connection: ${safeTerminalText2(name)}`, `Endpoint: ${safeTerminalText2(String(profile.endpoint ?? "missing"))} \u2192 ${safeTerminalText2(endpoint)}`, `Region: ${safeTerminalText2(String(profile.region ?? "auto"))} \u2192 ${safeTerminalText2(region)}`, `Credentials: ${safeTerminalText2(credentials.summary)}`, `Affected sync setups: ${usedBy.length > 0 ? usedBy.map(safeTerminalText2).join(", ") : "None"}`, "Saving changes future storage access for every affected setup; it does not move remote data." ].join("\n"), ["Save storage connection", "Cancel"], { signal } ); if (save !== "Save storage connection" || signal?.aborted) return; await updateStorageConnection( name, (current) => { if (current.type !== "s3") { throw new Error("Storage connection type changed; reopen it."); } return { ...current, endpoint, region, credentials: applyS3CredentialUpdate( current.credentials, credentials ) }; }, usedBy, signal ); if (signal?.aborted) return; ctx.ui.notify(`Saved storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info"); } async function showAddStorageConnection(ctx, signal) { if (ctx.mode !== "tui") { ctx.ui.notify( "Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.", "warning" ); return false; } const preset = await ctx.ui.select( "Storage type", ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"], { signal } ); if (signal?.aborted || !preset || preset === "Cancel") return false; if (preset === "WebDAV") return showAddWebDavStorageProfile(ctx, signal); if (preset === "Git") return showAddGitStorageProfile(ctx, signal); const name = await requiredInput( ctx, "Name this storage connection", preset === "Cloudflare R2" ? "r2" : "s3", signal ); if (!name || signal?.aborted) return false; const endpoint = await requiredInput( ctx, "Endpoint", preset === "Cloudflare R2" ? "https://.r2.cloudflarestorage.com" : "https://s3.example.com", signal ); if (!endpoint || signal?.aborted) return false; const region = preset === "Cloudflare R2" ? "auto" : await requiredInput(ctx, "Region", "us-east-1", signal); if (!region || signal?.aborted) return false; const credentials = await chooseS3Credentials(ctx, signal); if (!credentials || signal?.aborted) return false; const save = await ctx.ui.select( [ "Review storage connection", "", `Name: ${safeTerminalText2(name)}`, `Type: ${preset}`, `Endpoint: ${safeTerminalText2(endpoint)}`, `Region: ${safeTerminalText2(region)}`, `Credentials: ${safeTerminalText2(credentials.summary)}`, "Adding a connection does not contact remote storage or start syncing." ].join("\n"), ["Add storage connection", "Cancel"], { signal } ); if (save !== "Add storage connection" || signal?.aborted) return false; await addStorageConnection( name, { type: "s3", endpoint, region, credentials: { accessKeyId: credentials.profileFields.accessKeyId ?? "", secretAccessKey: credentials.profileFields.secretAccessKey ?? "" } }, signal ); if (signal?.aborted) return true; ctx.ui.notify(`Added storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info"); return true; } function referencingSetups(raw, connection) { return Object.entries(ownRecord(raw?.syncSetups) ?? {}).filter(([, value]) => ownRecord(ownRecord(value)?.storage)?.connection === connection).map(([name]) => name).sort((left, right) => left.localeCompare(right)); } function connectionType(profile) { if (profile.type === "git") return "Git"; if (profile.type === "webdav") return "WebDAV"; if (profile.type === "s3" && typeof profile.endpoint === "string" && isCloudflareR2Endpoint(profile.endpoint)) { return "Cloudflare R2"; } return "S3-compatible"; } function connectionEndpoint(profile) { const value = profile.type === "git" ? profile.remote : profile.type === "webdav" ? profile.url : profile.endpoint; if (typeof value !== "string" || value.length === 0) return "Missing"; if (profile.type === "git") return safeTerminalText2(value); try { return safeTerminalText2(new URL(value).host); } catch { return "Invalid"; } } function credentialSource(profile) { if (profile.type === "git") return "Git credential helper or SSH configuration"; const credentials = ownRecord(profile.credentials); if (profile.type === "webdav") return credentials?.password ? "Settings file" : "Missing"; if (credentials?.accessKeyId && credentials.secretAccessKey) return "Settings file"; return "Missing"; } // src/manager-ui.ts async function showSyncManager(ctx, runRoute, sessionSignal, options = {}) { if (!ctx.hasUI) { await runRoute("help"); return; } const menu = defineMenu4({ start: "main", screens: { main: ({ state }) => { const attentionItems = attentionMainMenuItems(state.manager); const managerItems = state.manager.actions.map( (label) => blockedSyncMenuItem(label, state.manager) ?? syncMainMenuItem(label) ); const operationFirst = state.manager.operation !== void 0 && state.manager.operation.kind !== "free"; return { kind: "actions", title: "Manage sync", lines: state.manager.title.split("\n").slice(1), items: operationFirst ? [...managerItems, ...attentionItems] : [...attentionItems, ...managerItems], hint: "close" }; }, more: () => ({ kind: "actions", title: "More options", items: [ { id: "pull", label: "Pull from remote\u2026", action: "pull" }, { id: "push", label: "Push to remote\u2026", action: "push" }, { id: "setups", label: "Sync setups\u2026", action: "setups" }, { id: "connections", label: "Storage connections\u2026", action: "connections" }, { id: "recovery", label: "History & recovery\u2026", to: "recovery" }, { id: "help", label: "Help", action: "help" }, { id: "back", label: "Back", action: "back" } ], hint: "back" }), recovery: ({ state }) => ({ kind: "actions", title: "History & recovery", items: [ { id: "history", label: "Browse history", action: "history" }, { id: "doctor", label: "Check setup", action: "doctor" }, ...state.manager.operation && operationCanRecover(state.manager.operation) ? [{ id: "unlock", label: "Recover stale operation", action: "unlock" }] : [], { id: "back", label: "Back", action: "back" } ], hint: "back" }) }, actions: { "review-attention": async () => { const attention = options.getAttention?.(); if (!attention) return { kind: "stay" }; const disposition = await showManagerAttention( ctx, attention, runRoute, sessionSignal, () => options.onSelectionResolved?.(attention) ); return { kind: disposition }; }, sync: async () => { const pendingAttention = options.getAttention?.(); if (pendingAttention) { const activeConfig = await loadConfig(); if (sessionSignal?.aborted) return { kind: "close" }; if (pendingAttention.decision.setupName === activeConfig.setupName) { ctx.ui.notify("Review synced content before starting Sync now.", "warning"); return { kind: "stay" }; } } const result = await runCancellableOperation( ctx, "Checking current sync setup\u2026", "sync", runRoute, { commitAware: true, signal: sessionSignal } ); const disposition = await dispatchManagerResult( ctx, result, "sync", runRoute, sessionSignal ); return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" }; }, switch: async () => { const result = await showSetupSwitcher(ctx, runRoute, void 0, sessionSignal); return result === "pull-attempted" || result === "closed" ? { kind: "close" } : { kind: "stay" }; }, diff: async () => { const result = await runCancellableOperation( ctx, "Checking current sync setup\u2026", "diff", runRoute, { signal: sessionSignal } ); return result.kind === "closed" ? { kind: "close" } : { kind: "stay" }; }, settings: async () => { await showSyncSettings(ctx, runRoute, sessionSignal); return { kind: "stay" }; }, pull: async () => { const result = await runCancellableOperation( ctx, "Checking remote changes\u2026", "pull", runRoute, { commitAware: true, cancelledMessage: "Pull check cancelled; no local files were changed.", signal: sessionSignal } ); const disposition = await dispatchManagerResult( ctx, result, "pull", runRoute, sessionSignal ); return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" }; }, push: async () => { const result = await runCancellableOperation( ctx, "Preparing push preview\u2026", "push", runRoute, { commitAware: true, cancelledMessage: "Push preparation cancelled; no remote files were changed.", signal: sessionSignal } ); const disposition = await dispatchManagerResult( ctx, result, "push", runRoute, sessionSignal ); return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" }; }, setups: async () => { const result = await showSyncSetupManager(ctx, runRoute, sessionSignal); return result === "exit" ? { kind: "close" } : { kind: "stay" }; }, connections: async () => { await showStorageConnections(ctx, sessionSignal); return { kind: "stay" }; }, history: async () => { await runRoute("history"); return { kind: "stay" }; }, doctor: async () => { await runRoute("doctor"); return { kind: "stay" }; }, unlock: async ({ state, signal: actionSignal }) => { const result = await recoverSyncAccess( ctx, state.manager, runRoute, sessionSignal, actionSignal ); if (result === "close") return { kind: "close" }; return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" }; }, recover: async ({ state, signal: actionSignal }) => { const result = await recoverSyncAccess( ctx, state.manager, runRoute, sessionSignal, actionSignal ); return { kind: result === "close" ? "close" : "stay" }; }, refresh: async () => ({ kind: "stay" }), help: async () => { await runRoute("help"); return { kind: "close" }; }, init: async () => { await runRoute("init"); return { kind: "stay" }; }, back: async () => ({ kind: "back" }) } }); await runMenu4(ctx, menu, { getState: async () => { const pendingAttention = options.getAttention?.(); const manager = await describeManagerState(sessionSignal, pendingAttention); if (pendingAttention && options.getAttention?.() === pendingAttention && !manager.attention) { options.onSelectionResolved?.(pendingAttention); } return { manager }; }, signal: sessionSignal, isCurrent: () => !sessionSignal?.aborted }); } function syncMainMenuItem(label) { if (label === "More\u2026") return { id: "more", label, to: "more" }; if (label === "History & recovery\u2026") return { id: "recovery", label, to: "recovery" }; const actions = /* @__PURE__ */ new Map([ ["Sync now (recommended)", "sync"], ["Switch sync setup", "switch"], ["Status & changes", "diff"], ["Settings", "settings"], ["Restore sync access\u2026 (recommended)", "recover"], ["Refresh operation status", "refresh"], ["Sync setups\u2026", "setups"], ["Storage connections\u2026", "connections"], ["Help", "help"], ["Set up sync", "init"], ["Use existing settings", "init"] ]); return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" }; } async function showSetupWizard(ctx, signal) { if (ctx.mode !== "tui") { ctx.ui.notify( `Guided sync setup requires TUI mode for masked credential input. Create version 3 settings in ${safeTerminalText2(localConfigPath())}.`, "warning" ); return false; } const preset = await ctx.ui.select( "Set up sync\n\nWhere will Pi settings be stored?", ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"], { signal } ); if (signal?.aborted || !preset || preset === "Cancel") return false; const targetName = await chooseInitialTargetName(ctx, signal); if (!targetName) return false; if (preset === "WebDAV") { const saved = await showWebDavSetup(ctx, targetName, signal); if (signal?.aborted) return false; if (saved) await refreshTargetCompletions(); return saved; } if (preset === "Git") { const saved = await showGitSetup(ctx, targetName, signal); if (signal?.aborted) return false; if (saved) await refreshTargetCompletions(); return saved; } const endpoint = await requiredInput( ctx, preset === "Cloudflare R2" ? "Cloudflare R2 endpoint" : "S3-compatible endpoint", preset === "Cloudflare R2" ? "https://.r2.cloudflarestorage.com" : "https://s3.example.com", signal ); if (!endpoint) return false; let region = "auto"; if (preset !== "Cloudflare R2") { const selectedRegion = await requiredInput(ctx, "Storage region", "us-east-1", signal); if (!selectedRegion) return false; region = selectedRegion; } const location = await chooseInitialRemoteLocation(ctx, preset, targetName, signal); if (!location) return false; const { connectionName, bucket, path: storagePath } = location; const credentials = await chooseS3Credentials(ctx, signal); if (!credentials) return false; const contentChoice = await ctx.ui.select( "Choose an initial sync preset", ["Recommended Pi settings", "Minimal settings", "Cancel"], { signal } ); if (signal?.aborted || !contentChoice || contentChoice === "Cancel") return false; const syncFiles = contentChoice === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE]; const automaticChoice = await ctx.ui.select( "Automatic sync for this setup", ["Enable automatic sync", "Keep automatic sync off", "Cancel"], { signal } ); if (signal?.aborted || !automaticChoice || automaticChoice === "Cancel") return false; const sessionChoice = await ctx.ui.select( "Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.", ["Keep sessions off (recommended)", "Include session conversations", "Cancel"], { signal } ); if (signal?.aborted || !sessionChoice || sessionChoice === "Cancel") return false; const syncSessions = sessionChoice === "Include session conversations"; if (syncSessions && !await ctx.ui.confirm( "Include session conversations?", "I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.", { signal } )) { return false; } const autoSync = automaticChoice === "Enable automatic sync"; const choice = await ctx.ui.select( [ "Review sync setup", "", `Sync setup: ${safeTerminalText2(targetName)}`, `Storage connection: ${safeTerminalText2(connectionName)} (${preset})`, `Endpoint: ${safeTerminalText2(endpoint)}`, `Bucket: ${safeTerminalText2(bucket)}`, `Storage location: ${safeTerminalText2(storagePath)}`, "Bucket must already exist. pi-sync will not create it.", `Included content: ${syncFiles.length} built-in groups \xB7 Sessions: ${syncSessions ? "On \u2014 privacy warning acknowledged" : "Off"}`, `Automatic sync: ${autoSync ? "On" : "Off"}`, `Credentials: ${safeTerminalText2(credentials.summary)}` ].join("\n"), ["Save sync setup", "Cancel"], { signal } ); if (signal?.aborted || choice !== "Save sync setup") return false; await saveNewV3Settings( { setupName: targetName, connectionName, connection: { type: "s3", endpoint, region, credentials: { accessKeyId: credentials.profileFields.accessKeyId ?? "", secretAccessKey: credentials.profileFields.secretAccessKey ?? "" } }, setup: { storage: { connection: connectionName, bucket, path: storagePath }, sync: { include: [...syncFiles, ...syncSessions ? ["sessions"] : []], automatic: autoSync } } }, signal ); if (signal?.aborted) return false; await refreshTargetCompletions(); if (signal?.aborted) return true; ctx.ui.notify( credentials.ready ? `Sync setup \u201C${safeTerminalText2(targetName)}\u201D is ready. Use Sync now when ready.` : `Saved sync setup \u201C${safeTerminalText2(targetName)}\u201D; add credentials before syncing.`, "info" ); return true; } async function selectSetupForSwitch(ctx, raw, targets, active, signal) { let selectedName; const nameById = /* @__PURE__ */ new Map(); const profiles = ownRecord(raw.storageConnections); const menu = defineMenu4({ start: "setups", screens: { setups: () => ({ kind: "actions", title: "Switch sync setup", lines: [`Current sync setup: ${safeTerminalText2(active ?? "none")}`], items: Object.keys(targets).sort((left, right) => left.localeCompare(right)).map((candidate, index) => { const target = ownRecord(targets[candidate]); const storage = ownRecord(target?.storage); const profileName = typeof storage?.connection === "string" ? storage.connection : void 0; const profile = profileName && profiles ? ownRecord(profiles[profileName]) : void 0; const location = profile ? profile.type === "git" ? `${String(storage?.branch ?? "missing branch")}:${String(storage?.path ?? "missing path")}` : profile.type === "s3" ? `${String(storage?.bucket ?? "missing bucket")}/${String(storage?.path ?? "missing path")}` : String(storage?.path ?? "missing path") : `invalid: missing connection ${profileName ?? "reference"}`; const id = `setup:${index}`; nameById.set(id, candidate); return { id, label: `${safeTerminalText2(candidate)}${candidate === active ? " (current)" : ""}`, description: `${safeTerminalText2(profileName ?? "unknown")} \xB7 ${safeTerminalText2(location)}`, action: "select" }; }), hint: "close" }) }, actions: { select: async ({ itemId }) => { selectedName = nameById.get(itemId); return { kind: "close" }; } } }); await runMenu4(ctx, menu, { getState: () => void 0, signal, isCurrent: () => !signal?.aborted }); return selectedName; } async function showSetupSwitcher(ctx, runRoute, selectedName, signal) { const raw = await readLocalConfigObject(); if (signal?.aborted) return false; if (raw?.version !== 3) { ctx.ui.notify("Add a second sync setup before switching setups.", "info"); return false; } const targets = ownRecord(raw.syncSetups); if (!targets) { ctx.ui.notify("No sync setups are configured.", "warning"); return false; } const active = typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : void 0; let name = selectedName; if (!name) { name = await selectSetupForSwitch(ctx, raw, targets, active, signal); if (!name) return false; } if (!name || !Object.hasOwn(targets, name)) { ctx.ui.notify( `Sync setup \u201C${safeTerminalText2(name ?? "unknown")}\u201D no longer exists.`, "warning" ); return false; } if (name === active) { ctx.ui.notify(`Sync setup \u201C${safeTerminalText2(name)}\u201D is already current.`, "info"); return false; } let config; try { config = await loadConfig(name); if (signal?.aborted) return false; } catch (error) { ctx.ui.notify( `Cannot use sync setup \u201C${safeTerminalText2(name)}\u201D: ${safeTerminalText2(errorMessage(error))}`, "error" ); return false; } const onSwitch = await loadOnSwitch(); if (signal?.aborted) return false; const switchEffect = onSwitch === "ask-before-pull" ? "After switching, pi-sync will ask whether to review a pull for this setup." : onSwitch === "pull-after-switch" ? "After switching, pi-sync will check this setup and show exact changes before applying them." : "After switching, pi-sync will not pull or modify synced files."; const confirmed = await ctx.ui.confirm( "Switch sync setup?", [ `From: ${safeTerminalText2(active ?? "none")}`, `To: ${safeTerminalText2(name)}`, `Storage: ${backendStorageDescription(config)}`, `Included content: ${config.include.length} paths`, `Automatic sync: ${config.automatic ? "On" : "Off"} \xB7 Sessions: ${config.include.includes("sessions") ? "On" : "Off"}`, "", switchEffect ].join("\n"), { signal } ); if (signal?.aborted || !confirmed) return false; try { let pullClosed = false; const result = await useSyncSetup( ctx, name, async (selectedTarget) => { const pullResult = await runCancellableOperation( ctx, `Pulling sync setup \u201C${safeTerminalText2(name)}\u201D\u2026`, "pull", runRoute, { commitAware: true, cancelledMessage: null, target: selectedTarget, signal } ); const disposition = await dispatchManagerResult(ctx, pullResult, "pull", runRoute, signal); if (pullResult.kind === "closed" || pullResult.kind.endsWith("required")) { pullClosed = disposition.kind === "close"; } if (disposition.appliedRoute === "pull") return "applied"; if (pullResult.kind === "completed") return pullResult.outcome; return pullResult.kind === "cancelled" ? "cancelled" : void 0; }, onSwitch, signal, syncConfigReviewIdentity(config) ); if (pullClosed) return "closed"; return result.pullApplied ? "pull-attempted" : "switched"; } catch (error) { if (signal?.aborted) return false; ctx.ui.notify( `Sync setup \u201C${safeTerminalText2(name)}\u201D was not switched: ${safeTerminalText2(errorMessage(error))}`, "error" ); return false; } } async function showSyncSetupManager(ctx, runRoute, signal) { return showSyncSetups( ctx, { add: async (setupSignal) => { await showAddTarget(ctx, setupSignal); }, edit: async (name, setupSignal) => { await showEditTarget(ctx, name, setupSignal); }, makeCurrent: async (name, setupSignal) => { const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal); return result === "pull-attempted" || result === "closed" ? "exit" : void 0; }, remove: async (name, setupSignal) => { await showRemoveTarget(ctx, name, setupSignal); } }, signal ); } async function showAddTarget(ctx, signal) { let raw = await readLocalConfigObject(); if (signal?.aborted) return; if (!raw) return void ctx.ui.notify("Set up the first sync setup before adding another.", "info"); if (raw.version !== 3) { ctx.ui.notify( "Version 1 and version 2 settings are unsupported and are never migrated.", "error" ); return; } let profiles = ownRecord(raw.storageConnections) ?? {}; const name = await requiredInput(ctx, "Name the new sync setup", "work", signal); if (!name) return; const createConnection = "Add a new storage connection\u2026"; let profile = await ctx.ui.select( "Choose a storage connection", [...Object.keys(profiles).sort(), createConnection, "Cancel"], { signal } ); if (!profile || profile === "Cancel") return; if (profile === createConnection) { const previousNames = new Set(Object.keys(profiles)); if (!await showAddStorageConnection(ctx, signal)) return; if (signal?.aborted) return; raw = await readLocalConfigObject() ?? raw; if (signal?.aborted) return; profiles = ownRecord(raw.storageConnections) ?? {}; profile = Object.keys(profiles).find((candidate) => !previousNames.has(candidate)); if (!profile) return; } const storageKind = ownRecord(profiles[profile])?.type; if (storageKind === "webdav") { const saved = await showAddWebDavTarget(ctx, name, profile, signal); if (signal?.aborted) return; if (saved) await refreshTargetCompletions(); return; } if (storageKind === "git") { const saved = await showAddGitTarget(ctx, name, profile, signal); if (signal?.aborted) return; if (saved) await refreshTargetCompletions(); return; } const location = await chooseAdditionalRemoteLocation(ctx, raw, profile, name, signal); if (!location) return; const { bucket, path: storagePath } = location; const preset = await ctx.ui.select( "Choose included content", ["Recommended Pi settings", "Minimal settings", "Cancel"], { signal } ); if (!preset || preset === "Cancel") return; const syncFiles = preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE]; const overlapsExistingTarget = Object.values(ownRecord(raw.syncSetups) ?? {}).some((value) => { const existing = ownRecord(value); const sync = ownRecord(existing?.sync); const selected = syncIncludeSelection( Array.isArray(sync?.include) ? sync.include : [] ).builtIns; return selected.some((item) => syncFiles.includes(item)); }); const choice = await ctx.ui.select( [ "Review new sync setup", "", `Sync setup: ${safeTerminalText2(name)}`, `Storage connection: ${safeTerminalText2(profile)}`, `Bucket: ${safeTerminalText2(bucket)}`, `Storage location: ${safeTerminalText2(storagePath)}`, "Bucket must already exist. pi-sync will not create it.", `Included content: ${syncFiles.length} built-in groups \xB7 Sessions: Off`, ...overlapsExistingTarget ? [ "Warning: this setup shares local content with another setup; only the current setup syncs automatically." ] : [], "Adding this setup does not sync or modify remote data." ].join("\n"), ["Add sync setup", "Cancel"], { signal } ); if (signal?.aborted || choice !== "Add sync setup") return; await addSyncSetup( name, { storage: { connection: profile, bucket, path: storagePath }, sync: { include: syncFiles, automatic: true } }, signal ); if (signal?.aborted) return; await refreshTargetCompletions(); ctx.ui.notify(`Added sync setup \u201C${safeTerminalText2(name)}\u201D.`, "info"); } async function showEditTarget(ctx, name, signal) { const partial = await loadPartialConfig(name); if (signal?.aborted) return; if (!partial.setupName) { ctx.ui.notify("Create version 3 settings before editing a named sync setup.", "info"); return; } if (partial.storageKind === "webdav") { await showEditWebDavTarget(ctx, partial, signal); return; } if (partial.storageKind === "git") { await showEditGitTarget(ctx, partial, signal); return; } const bucket = await requiredInput(ctx, "Bucket", partial.bucket ?? "pi-sync", signal); if (!bucket) return; const storagePath = await requiredInput(ctx, "Storage path", partial.storagePath, signal); if (!storagePath) return; const normalizedPath = storagePath.replace(/^\/+|\/+$/gu, ""); const choice = await ctx.ui.select( [ `Review sync setup \u201C${safeTerminalText2(partial.setupName)}\u201D`, "", `Bucket: ${safeTerminalText2(partial.bucket ?? "missing")} \u2192 ${safeTerminalText2(bucket)}`, `Storage path: ${safeTerminalText2(partial.storagePath ?? "missing")} \u2192 ${safeTerminalText2(normalizedPath)}`, "Saving changes the future storage location only; it does not move or delete remote data." ].join("\n"), ["Save sync setup", "Cancel"], { signal } ); if (signal?.aborted || choice !== "Save sync setup") return; await updateSyncSetup( partial.setupName, (setup) => { if (typeof setup.storage.bucket !== "string") { throw new Error("Sync setup storage type changed; reopen it."); } return { ...setup, storage: { ...setup.storage, bucket, path: normalizedPath } }; }, { expectedStorage: partial, signal } ); if (signal?.aborted) return; ctx.ui.notify(`Saved sync setup \u201C${safeTerminalText2(partial.setupName)}\u201D.`, "info"); } async function showRemoveTarget(ctx, name, signal) { const confirmed = await ctx.ui.confirm( "Remove sync setup?", `Remove local sync setup \u201C${safeTerminalText2(name)}\u201D? Remote data and history are not deleted.`, { signal } ); if (signal?.aborted || !confirmed) return; await removeSyncSetup(name, signal); if (signal?.aborted) return; await refreshTargetCompletions(); ctx.ui.notify( `Removed sync setup \u201C${safeTerminalText2(name)}\u201D; remote data was not deleted.`, "info" ); } async function refreshTargetCompletions() { setSyncSetupCompletions(await configuredSyncSetupNames()); } async function chooseInitialTargetName(ctx, signal) { const purpose = await ctx.ui.select( "What will this sync setup be used for?", ["Personal / Home", "Work", "Custom", "Cancel"], { signal } ); if (!purpose || purpose === "Cancel") return void 0; if (purpose === "Personal / Home") return "home"; if (purpose === "Work") return "work"; return requiredInput(ctx, "Name this sync setup", "default", signal); } async function chooseInitialRemoteLocation(ctx, preset, setupName, signal) { const connectionName = preset === "Cloudflare R2" ? "r2" : "s3"; const suggested = { connectionName, bucket: "pi-sync", path: `pi-sync/${setupName}` }; if (preset === "Cloudflare R2") { const choice2 = await ctx.ui.select( [ "Choose storage location", "", `Suggested storage connection: ${connectionName}`, `Suggested bucket: ${suggested.bucket}`, `Remote path: ${safeTerminalText2(suggested.path)}`, "Bucket must already exist. pi-sync will not create it." ].join("\n"), ["Use suggested location (recommended)", "Customize remote location", "Cancel"], { signal } ); if (!choice2 || choice2 === "Cancel") return void 0; if (choice2 === "Use suggested location (recommended)") return suggested; return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal); } const choice = await ctx.ui.select( [ "Choose storage location", "", `Suggested storage connection: ${connectionName}`, `Suggested path: ${safeTerminalText2(suggested.path)}`, "S3 bucket names may need to be globally unique and the bucket must already exist." ].join("\n"), [ "Use existing bucket with suggested path (recommended)", "Customize remote location", "Cancel" ], { signal } ); if (!choice || choice === "Cancel") return void 0; if (choice === "Customize remote location") { return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal); } const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal); return bucket ? { ...suggested, bucket } : void 0; } async function chooseAdditionalRemoteLocation(ctx, settings, connectionName, setupName, signal) { const setups = ownRecord(settings.syncSetups) ?? {}; const currentSetup = typeof settings.activeSyncSetup === "string" ? settings.activeSyncSetup : void 0; const candidates = Object.entries(setups).map(([name, value]) => ({ name, storage: ownRecord(ownRecord(value)?.storage) })).filter( (item) => item.storage?.connection === connectionName && typeof item.storage.bucket === "string" ); const source = candidates.find((item) => item.name === currentSetup) ?? candidates.sort((left, right) => left.name.localeCompare(right.name))[0]; if (source) { const sourcePath = typeof source.storage.path === "string" ? source.storage.path : "pi-sync/home"; const sourceParent = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "pi-sync"; const suggestedPath2 = `${sourceParent}/${setupName}`; const sameBucketLabel = `Same bucket as \u201C${safeTerminalText2(source.name)}\u201D (recommended)`; const choice2 = await ctx.ui.select( [ `Storage location for \u201C${safeTerminalText2(setupName)}\u201D`, "", `Recommended bucket: ${safeTerminalText2(String(source.storage.bucket))}`, `Remote path: ${safeTerminalText2(suggestedPath2)}`, "The complete path and local sync state remain separate." ].join("\n"), [sameBucketLabel, "Use a different bucket", "Customize remote location", "Cancel"], { signal } ); if (!choice2 || choice2 === "Cancel") return void 0; if (choice2 === sameBucketLabel) { return { bucket: String(source.storage.bucket), path: suggestedPath2 }; } if (choice2 === "Use a different bucket") { const bucket2 = await requiredExistingBucket(ctx, "pi-sync", signal); return bucket2 ? { bucket: bucket2, path: `pi-sync/${setupName}` } : void 0; } const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal); return custom ? { bucket: custom.bucket, path: custom.path } : void 0; } const connectionSettings = ownRecord(ownRecord(settings.storageConnections)?.[connectionName]); const isR2 = isCloudflareR2Endpoint(String(connectionSettings?.endpoint ?? "")); const suggestedPath = `pi-sync/${setupName}`; const suggestedLabel = isR2 ? "Use suggested location (recommended)" : "Use existing bucket with suggested path (recommended)"; const choice = await ctx.ui.select( `Storage location for \u201C${safeTerminalText2(setupName)}\u201D Suggested path: ${safeTerminalText2(suggestedPath)}`, [suggestedLabel, "Customize remote location", "Cancel"], { signal } ); if (!choice || choice === "Cancel") return void 0; if (choice === "Customize remote location") { const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal); return custom ? { bucket: custom.bucket, path: custom.path } : void 0; } if (isR2) return { bucket: "pi-sync", path: suggestedPath }; const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal); return bucket ? { bucket, path: suggestedPath } : void 0; } async function chooseCustomRemoteLocation(ctx, setupName, initialConnectionName, customizeConnectionName, signal) { const connectionName = customizeConnectionName ? await requiredInput(ctx, "Storage connection name", initialConnectionName, signal) : initialConnectionName; if (!connectionName) return void 0; const bucket = await requiredExistingBucket(ctx, "pi-sync", signal); if (!bucket) return void 0; const storagePath = await requiredInput(ctx, "Storage path", `pi-sync/${setupName}`, signal); if (!storagePath) return void 0; return { connectionName, bucket, path: storagePath.replace(/^\/+|\/+$/gu, "") }; } export { showSetupWizard, showSyncManager }; //# sourceMappingURL=manager-ui-7E25VWUP.ts.map