@narumitw/pi-sync 0.49.10 → 0.49.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/chunks/chunk-3GRWMWUZ.ts +213 -0
- package/dist/chunks/chunk-3GRWMWUZ.ts.map +7 -0
- package/dist/chunks/chunk-3NDD5QPM.ts +203 -0
- package/dist/chunks/chunk-3NDD5QPM.ts.map +7 -0
- package/dist/chunks/chunk-4F3W4CSC.ts +195 -0
- package/dist/chunks/chunk-4F3W4CSC.ts.map +7 -0
- package/dist/chunks/chunk-4U6AFLJB.ts +221 -0
- package/dist/chunks/chunk-4U6AFLJB.ts.map +7 -0
- package/dist/chunks/chunk-5MQ76BK5.ts +34 -0
- package/dist/chunks/chunk-5MQ76BK5.ts.map +7 -0
- package/dist/chunks/chunk-APZVBSM6.ts +1238 -0
- package/dist/chunks/chunk-APZVBSM6.ts.map +7 -0
- package/dist/chunks/chunk-BHJUISB4.ts +134 -0
- package/dist/chunks/chunk-BHJUISB4.ts.map +7 -0
- package/dist/chunks/chunk-D3S54OXI.ts +653 -0
- package/dist/chunks/chunk-D3S54OXI.ts.map +7 -0
- package/dist/chunks/chunk-EWX3TPLQ.ts +356 -0
- package/dist/chunks/chunk-EWX3TPLQ.ts.map +7 -0
- package/dist/chunks/chunk-F5QL2GPY.ts +341 -0
- package/dist/chunks/chunk-F5QL2GPY.ts.map +7 -0
- package/dist/chunks/chunk-FR6CVXAL.ts +81 -0
- package/dist/chunks/chunk-FR6CVXAL.ts.map +7 -0
- package/dist/chunks/chunk-HGV24P4T.ts +53 -0
- package/dist/chunks/chunk-HGV24P4T.ts.map +7 -0
- package/dist/chunks/chunk-JNOOSPEW.ts +82 -0
- package/dist/chunks/chunk-JNOOSPEW.ts.map +7 -0
- package/dist/chunks/chunk-LJDSHNS3.ts +259 -0
- package/dist/chunks/chunk-LJDSHNS3.ts.map +7 -0
- package/dist/chunks/chunk-RGJ6SOYR.ts +128 -0
- package/dist/chunks/chunk-RGJ6SOYR.ts.map +7 -0
- package/dist/chunks/chunk-W64NEHXT.ts +167 -0
- package/dist/chunks/chunk-W64NEHXT.ts.map +7 -0
- package/dist/chunks/chunk-YQ6UW7IF.ts +394 -0
- package/dist/chunks/chunk-YQ6UW7IF.ts.map +7 -0
- package/dist/chunks/file-selection-4UABOP44.ts +290 -0
- package/dist/chunks/file-selection-4UABOP44.ts.map +7 -0
- package/dist/chunks/git-backend-E3UOUUOM.ts +1104 -0
- package/dist/chunks/git-backend-E3UOUUOM.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts +18 -0
- package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts.map +7 -0
- package/dist/chunks/manager-ui-7E25VWUP.ts +2754 -0
- package/dist/chunks/manager-ui-7E25VWUP.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts +17 -0
- package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts.map +7 -0
- package/dist/chunks/s3-backend-3QZGO2EF.ts +680 -0
- package/dist/chunks/s3-backend-3QZGO2EF.ts.map +7 -0
- package/dist/chunks/setup-switch-X3W3FALZ.ts +27 -0
- package/dist/chunks/setup-switch-X3W3FALZ.ts.map +7 -0
- package/dist/chunks/snapshot-WBSV4MQG.ts +45 -0
- package/dist/chunks/snapshot-WBSV4MQG.ts.map +7 -0
- package/dist/chunks/sync-operations-MPAFVVHA.ts +1112 -0
- package/dist/chunks/sync-operations-MPAFVVHA.ts.map +7 -0
- package/dist/chunks/sync-state-36H7ULG2.ts +41 -0
- package/dist/chunks/sync-state-36H7ULG2.ts.map +7 -0
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts +869 -0
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +7 -0
- package/dist/index.ts +668 -0
- package/dist/index.ts.map +7 -0
- package/package.json +10 -6
|
@@ -0,0 +1,2754 @@
|
|
|
1
|
+
// @generated by scripts/build-runtime.mjs; do not edit.
|
|
2
|
+
// @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
|
|
3
|
+
import {
|
|
4
|
+
dispatchManagerResult
|
|
5
|
+
} from "./chunk-3GRWMWUZ.js";
|
|
6
|
+
import {
|
|
7
|
+
errorMessage,
|
|
8
|
+
ownRecord,
|
|
9
|
+
requiredExistingBucket,
|
|
10
|
+
requiredInput,
|
|
11
|
+
runCancellableOperation,
|
|
12
|
+
safeTerminalText as safeTerminalText2
|
|
13
|
+
} from "./chunk-D3S54OXI.js";
|
|
14
|
+
import {
|
|
15
|
+
addStorageConnection,
|
|
16
|
+
addSyncSetup,
|
|
17
|
+
removeStorageConnection,
|
|
18
|
+
removeSyncSetup,
|
|
19
|
+
saveNewV3Settings,
|
|
20
|
+
updateStorageConnection,
|
|
21
|
+
updateSyncSetup
|
|
22
|
+
} from "./chunk-4F3W4CSC.js";
|
|
23
|
+
import {
|
|
24
|
+
syncAttentionMatchesConfig
|
|
25
|
+
} from "./chunk-JNOOSPEW.js";
|
|
26
|
+
import {
|
|
27
|
+
SETUP_SWITCH_ACTION_OPTIONS,
|
|
28
|
+
saveOnSwitch,
|
|
29
|
+
setupSwitchActionFromLabel,
|
|
30
|
+
setupSwitchActionLabel,
|
|
31
|
+
useSyncSetup
|
|
32
|
+
} from "./chunk-BHJUISB4.js";
|
|
33
|
+
import {
|
|
34
|
+
setSyncSetupCompletions
|
|
35
|
+
} from "./chunk-4U6AFLJB.js";
|
|
36
|
+
import "./chunk-FR6CVXAL.js";
|
|
37
|
+
import {
|
|
38
|
+
inspectLock,
|
|
39
|
+
isLockGuardHeld,
|
|
40
|
+
isStaleLock
|
|
41
|
+
} from "./chunk-LJDSHNS3.js";
|
|
42
|
+
import {
|
|
43
|
+
safeTerminalText
|
|
44
|
+
} from "./chunk-W64NEHXT.js";
|
|
45
|
+
import "./chunk-RGJ6SOYR.js";
|
|
46
|
+
import "./chunk-EWX3TPLQ.js";
|
|
47
|
+
import {
|
|
48
|
+
activeLocalConfigPath,
|
|
49
|
+
configuredSyncSetupNames,
|
|
50
|
+
isCloudflareR2Endpoint,
|
|
51
|
+
loadConfig,
|
|
52
|
+
loadOnSwitch,
|
|
53
|
+
loadPartialConfig,
|
|
54
|
+
localConfigPath,
|
|
55
|
+
normalizeWebDavPath,
|
|
56
|
+
normalizeWebDavUrl,
|
|
57
|
+
readLocalConfigObject,
|
|
58
|
+
readStateForConfig,
|
|
59
|
+
syncConfigReviewIdentity,
|
|
60
|
+
validateWebDavCredentials,
|
|
61
|
+
validateWebDavNamespace
|
|
62
|
+
} from "./chunk-APZVBSM6.js";
|
|
63
|
+
import {
|
|
64
|
+
normalizeGitBranch,
|
|
65
|
+
normalizeGitDirectory,
|
|
66
|
+
normalizeGitRemote
|
|
67
|
+
} from "./chunk-F5QL2GPY.js";
|
|
68
|
+
import {
|
|
69
|
+
DEFAULT_SYNC_INCLUDE,
|
|
70
|
+
compareSyncInclude,
|
|
71
|
+
syncIncludeSelection
|
|
72
|
+
} from "./chunk-YQ6UW7IF.js";
|
|
73
|
+
|
|
74
|
+
// src/manager-ui.ts
|
|
75
|
+
import { defineMenu as defineMenu4, runMenu as runMenu4 } from "@narumitw/pi-tui-kit";
|
|
76
|
+
|
|
77
|
+
// src/git-ui.ts
|
|
78
|
+
async function showGitSetup(ctx, targetName, signal) {
|
|
79
|
+
const profileName = await requiredInput(ctx, "Name this Git storage connection", "git", signal);
|
|
80
|
+
if (!profileName) return false;
|
|
81
|
+
const remoteInput = await requiredInput(
|
|
82
|
+
ctx,
|
|
83
|
+
"Git SSH or HTTPS remote",
|
|
84
|
+
"git@github.com:owner/private-pi-sync.git",
|
|
85
|
+
signal
|
|
86
|
+
);
|
|
87
|
+
if (!remoteInput) return false;
|
|
88
|
+
const destination = await promptGitDestination(ctx, targetName, signal);
|
|
89
|
+
if (!destination) return false;
|
|
90
|
+
const automatic = await ctx.ui.select(
|
|
91
|
+
"Automatic sync for this setup",
|
|
92
|
+
["Enable automatic sync", "Keep automatic sync off", "Cancel"],
|
|
93
|
+
{ signal }
|
|
94
|
+
);
|
|
95
|
+
throwIfAborted(signal);
|
|
96
|
+
if (!automatic || automatic === "Cancel") return false;
|
|
97
|
+
let remote;
|
|
98
|
+
try {
|
|
99
|
+
remote = normalizeGitRemote(remoteInput);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (!remote) return false;
|
|
105
|
+
const choice = await ctx.ui.select(
|
|
106
|
+
[
|
|
107
|
+
"Review Git sync setup",
|
|
108
|
+
"",
|
|
109
|
+
`Sync setup: ${safeTerminalText2(targetName)}`,
|
|
110
|
+
`Storage connection: ${safeTerminalText2(profileName)} (Git)`,
|
|
111
|
+
`Remote: ${safeGitRemote(remote)}`,
|
|
112
|
+
`Owned branch: ${safeTerminalText2(destination.branch)}`,
|
|
113
|
+
`Storage location: ${safeTerminalText2(destination.directory)}`,
|
|
114
|
+
`Included content: ${DEFAULT_SYNC_INCLUDE.length} built-in groups \xB7 Sessions: Off`,
|
|
115
|
+
`Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}`,
|
|
116
|
+
"Authentication: existing non-interactive Git/SSH credentials; no credentials are stored by pi-sync.",
|
|
117
|
+
"The remote repository must already exist. The owned branch may be created on first push."
|
|
118
|
+
].join("\n"),
|
|
119
|
+
["Save setup", "Cancel"],
|
|
120
|
+
{ signal }
|
|
121
|
+
);
|
|
122
|
+
throwIfAborted(signal);
|
|
123
|
+
if (choice !== "Save setup") return false;
|
|
124
|
+
await saveNewV3Settings(
|
|
125
|
+
{
|
|
126
|
+
setupName: targetName,
|
|
127
|
+
connectionName: profileName,
|
|
128
|
+
connection: { type: "git", remote },
|
|
129
|
+
setup: {
|
|
130
|
+
storage: {
|
|
131
|
+
connection: profileName,
|
|
132
|
+
branch: destination.branch,
|
|
133
|
+
path: destination.directory
|
|
134
|
+
},
|
|
135
|
+
sync: {
|
|
136
|
+
include: [...DEFAULT_SYNC_INCLUDE],
|
|
137
|
+
automatic: automatic === "Enable automatic sync"
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
signal
|
|
142
|
+
);
|
|
143
|
+
if (signal?.aborted) return true;
|
|
144
|
+
ctx.ui.notify(
|
|
145
|
+
`Saved Git sync setup \u201C${safeTerminalText2(targetName)}\u201D. Run /sync doctor.`,
|
|
146
|
+
"info"
|
|
147
|
+
);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
async function showAddGitStorageProfile(ctx, signal) {
|
|
151
|
+
const name = await requiredInput(ctx, "Name this Git storage connection", "git", signal);
|
|
152
|
+
if (!name) return false;
|
|
153
|
+
const remoteInput = await requiredInput(
|
|
154
|
+
ctx,
|
|
155
|
+
"Git SSH or HTTPS remote",
|
|
156
|
+
"git@github.com:owner/private-pi-sync.git",
|
|
157
|
+
signal
|
|
158
|
+
);
|
|
159
|
+
if (!remoteInput) return false;
|
|
160
|
+
let remote;
|
|
161
|
+
try {
|
|
162
|
+
remote = normalizeGitRemote(remoteInput);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (!remote) return false;
|
|
168
|
+
const choice = await ctx.ui.select(
|
|
169
|
+
`Review storage connection
|
|
170
|
+
|
|
171
|
+
Name: ${safeTerminalText2(name)}
|
|
172
|
+
Type: Git
|
|
173
|
+
Remote: ${safeGitRemote(remote)}
|
|
174
|
+
Credentials: existing Git/SSH authentication (not stored)
|
|
175
|
+
Adding a connection does not contact the remote or start syncing.`,
|
|
176
|
+
["Add storage connection", "Cancel"],
|
|
177
|
+
{ signal }
|
|
178
|
+
);
|
|
179
|
+
throwIfAborted(signal);
|
|
180
|
+
if (choice !== "Add storage connection") return false;
|
|
181
|
+
await addStorageConnection(name, { type: "git", remote }, signal);
|
|
182
|
+
if (signal?.aborted) return true;
|
|
183
|
+
ctx.ui.notify(`Added storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
async function showEditGitStorageProfile(ctx, name, profile, signal, affectedSetups) {
|
|
187
|
+
const remoteInput = await requiredInput(
|
|
188
|
+
ctx,
|
|
189
|
+
"Git SSH or HTTPS remote",
|
|
190
|
+
typeof profile.remote === "string" ? profile.remote : "git@github.com:owner/private-pi-sync.git",
|
|
191
|
+
signal
|
|
192
|
+
);
|
|
193
|
+
if (!remoteInput) return false;
|
|
194
|
+
let remote;
|
|
195
|
+
try {
|
|
196
|
+
remote = normalizeGitRemote(remoteInput);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (!remote) return false;
|
|
202
|
+
const choice = await ctx.ui.select(
|
|
203
|
+
`Review storage connection
|
|
204
|
+
|
|
205
|
+
Storage connection: ${safeTerminalText2(name)}
|
|
206
|
+
Remote: ${safeGitRemote(String(profile.remote ?? "missing"))} \u2192 ${safeGitRemote(remote)}
|
|
207
|
+
Affected sync setups: ${affectedSetups && affectedSetups.length > 0 ? affectedSetups.map(safeTerminalText2).join(", ") : "None"}
|
|
208
|
+
Saving changes future storage access for every affected setup; it does not move or delete remote history.`,
|
|
209
|
+
["Save storage connection", "Cancel"],
|
|
210
|
+
{ signal }
|
|
211
|
+
);
|
|
212
|
+
throwIfAborted(signal);
|
|
213
|
+
if (choice !== "Save storage connection") return false;
|
|
214
|
+
await updateStorageConnection(
|
|
215
|
+
name,
|
|
216
|
+
(current) => {
|
|
217
|
+
if (current.type !== "git") throw new Error("Storage connection type changed; reopen it.");
|
|
218
|
+
return { ...current, remote };
|
|
219
|
+
},
|
|
220
|
+
affectedSetups,
|
|
221
|
+
signal
|
|
222
|
+
);
|
|
223
|
+
if (signal?.aborted) return true;
|
|
224
|
+
ctx.ui.notify(`Saved storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
async function showAddGitTarget(ctx, name, profile, signal) {
|
|
228
|
+
const destination = await promptGitDestination(ctx, name, signal);
|
|
229
|
+
if (!destination) return false;
|
|
230
|
+
const preset = await ctx.ui.select(
|
|
231
|
+
"Choose included content",
|
|
232
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
233
|
+
{ signal }
|
|
234
|
+
);
|
|
235
|
+
throwIfAborted(signal);
|
|
236
|
+
if (!preset || preset === "Cancel") return false;
|
|
237
|
+
const syncFiles = preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
|
|
238
|
+
const automatic = await ctx.ui.select(
|
|
239
|
+
"Automatic sync for this setup",
|
|
240
|
+
["Enable automatic sync", "Keep automatic sync off", "Cancel"],
|
|
241
|
+
{ signal }
|
|
242
|
+
);
|
|
243
|
+
throwIfAborted(signal);
|
|
244
|
+
if (!automatic || automatic === "Cancel") return false;
|
|
245
|
+
const choice = await ctx.ui.select(
|
|
246
|
+
`Review Git sync setup
|
|
247
|
+
|
|
248
|
+
Sync setup: ${safeTerminalText2(name)}
|
|
249
|
+
Storage connection: ${safeTerminalText2(profile)}
|
|
250
|
+
Owned branch: ${safeTerminalText2(destination.branch)}
|
|
251
|
+
Storage location: ${safeTerminalText2(destination.directory)}
|
|
252
|
+
Included content: ${syncFiles.length} built-in groups \xB7 Sessions: Off
|
|
253
|
+
Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}`,
|
|
254
|
+
["Add sync setup", "Cancel"],
|
|
255
|
+
{ signal }
|
|
256
|
+
);
|
|
257
|
+
throwIfAborted(signal);
|
|
258
|
+
if (choice !== "Add sync setup") return false;
|
|
259
|
+
await addSyncSetup(
|
|
260
|
+
name,
|
|
261
|
+
{
|
|
262
|
+
storage: {
|
|
263
|
+
connection: profile,
|
|
264
|
+
branch: destination.branch,
|
|
265
|
+
path: destination.directory
|
|
266
|
+
},
|
|
267
|
+
sync: {
|
|
268
|
+
include: syncFiles,
|
|
269
|
+
automatic: automatic === "Enable automatic sync"
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
signal
|
|
273
|
+
);
|
|
274
|
+
if (signal?.aborted) return true;
|
|
275
|
+
ctx.ui.notify(`Added sync setup \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
async function showEditGitTarget(ctx, partial, signal) {
|
|
279
|
+
const targetName = partial.setupName;
|
|
280
|
+
const destination = await promptGitDestination(ctx, targetName, signal, partial);
|
|
281
|
+
if (!destination) return false;
|
|
282
|
+
if (destination.directory !== partial.storagePath && destination.branch === partial.branch) {
|
|
283
|
+
ctx.ui.notify(
|
|
284
|
+
"Changing a Git storage path requires a new Git branch so the existing branch remains readable.",
|
|
285
|
+
"warning"
|
|
286
|
+
);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
const choice = await ctx.ui.select(
|
|
290
|
+
`Review sync setup \u201C${safeTerminalText2(targetName)}\u201D
|
|
291
|
+
|
|
292
|
+
Branch: ${safeTerminalText2(partial.branch ?? "pi-sync")} \u2192 ${safeTerminalText2(destination.branch)}
|
|
293
|
+
Storage path: ${safeTerminalText2(partial.storagePath)} \u2192 ${safeTerminalText2(destination.directory)}
|
|
294
|
+
Saving changes the future storage location only; it does not move or delete remote history.`,
|
|
295
|
+
["Save sync setup", "Cancel"],
|
|
296
|
+
{ signal }
|
|
297
|
+
);
|
|
298
|
+
throwIfAborted(signal);
|
|
299
|
+
if (choice !== "Save sync setup") return false;
|
|
300
|
+
await updateSyncSetup(
|
|
301
|
+
targetName,
|
|
302
|
+
(setup) => {
|
|
303
|
+
if (typeof setup.storage.branch !== "string") {
|
|
304
|
+
throw new Error("Sync setup storage type changed; reopen it.");
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
...setup,
|
|
308
|
+
storage: {
|
|
309
|
+
...setup.storage,
|
|
310
|
+
branch: destination.branch,
|
|
311
|
+
path: destination.directory
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
{ expectedStorage: partial, signal }
|
|
316
|
+
);
|
|
317
|
+
if (signal?.aborted) return true;
|
|
318
|
+
ctx.ui.notify(`Saved sync setup \u201C${safeTerminalText2(targetName)}\u201D.`, "info");
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
async function promptGitDestination(ctx, targetName, signal, current = {}) {
|
|
322
|
+
const branchInput = await requiredInput(
|
|
323
|
+
ctx,
|
|
324
|
+
"Owned Git branch",
|
|
325
|
+
current.branch ?? `pi-sync/${targetName}`,
|
|
326
|
+
signal
|
|
327
|
+
);
|
|
328
|
+
if (!branchInput) return void 0;
|
|
329
|
+
const pathInput = await requiredInput(
|
|
330
|
+
ctx,
|
|
331
|
+
"Git storage path",
|
|
332
|
+
current.storagePath ?? `pi-sync/${targetName}`,
|
|
333
|
+
signal
|
|
334
|
+
);
|
|
335
|
+
if (!pathInput) return void 0;
|
|
336
|
+
try {
|
|
337
|
+
const branch = normalizeGitBranch(branchInput);
|
|
338
|
+
const directory = normalizeGitDirectory(pathInput);
|
|
339
|
+
const namespace = directory.slice(directory.lastIndexOf("/") + 1);
|
|
340
|
+
return { branch, directory, namespace };
|
|
341
|
+
} catch (error) {
|
|
342
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
343
|
+
return void 0;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function throwIfAborted(signal) {
|
|
347
|
+
if (!signal?.aborted) return;
|
|
348
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
349
|
+
}
|
|
350
|
+
function safeGitRemote(remote) {
|
|
351
|
+
try {
|
|
352
|
+
const url = new URL(remote);
|
|
353
|
+
return safeTerminalText2(`${url.protocol}//${url.host}${url.pathname}`);
|
|
354
|
+
} catch {
|
|
355
|
+
return safeTerminalText2(remote);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/manager-attention.ts
|
|
360
|
+
function attentionMainMenuItems(manager) {
|
|
361
|
+
if (!manager.attention) return [];
|
|
362
|
+
const disabled = manager.attentionReviewDisabled === true;
|
|
363
|
+
return [
|
|
364
|
+
{
|
|
365
|
+
id: "review-attention",
|
|
366
|
+
label: "Review synced content (recommended)",
|
|
367
|
+
action: "review-attention",
|
|
368
|
+
...disabled ? {
|
|
369
|
+
disabled: true,
|
|
370
|
+
disabledReason: "Finish or recover the active operation first."
|
|
371
|
+
} : {}
|
|
372
|
+
}
|
|
373
|
+
];
|
|
374
|
+
}
|
|
375
|
+
function blockedSyncMenuItem(label, manager) {
|
|
376
|
+
if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return void 0;
|
|
377
|
+
return {
|
|
378
|
+
id: "sync",
|
|
379
|
+
label,
|
|
380
|
+
description: "Review first.",
|
|
381
|
+
action: "sync",
|
|
382
|
+
disabled: true,
|
|
383
|
+
disabledReason: "Review synced content first."
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async function showManagerAttention(ctx, attention, runRoute, signal, onSelectionResolved) {
|
|
387
|
+
const { showRemoteSelectionReview } = await import("./remote-selection-ui-J6OW5DKZ.js");
|
|
388
|
+
if (signal?.aborted) return "close";
|
|
389
|
+
const review = await showRemoteSelectionReview(
|
|
390
|
+
ctx,
|
|
391
|
+
attention.decision.setupName,
|
|
392
|
+
signal,
|
|
393
|
+
void 0,
|
|
394
|
+
{
|
|
395
|
+
decision: attention.decision,
|
|
396
|
+
origin: attention.origin,
|
|
397
|
+
runRoute,
|
|
398
|
+
onSelectionResolved
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
if (review.kind === "route-result") {
|
|
402
|
+
const disposition = await dispatchManagerResult(
|
|
403
|
+
ctx,
|
|
404
|
+
review.result,
|
|
405
|
+
review.route,
|
|
406
|
+
runRoute,
|
|
407
|
+
signal,
|
|
408
|
+
{ onSelectionResolved }
|
|
409
|
+
);
|
|
410
|
+
return disposition.kind;
|
|
411
|
+
}
|
|
412
|
+
return review.kind === "closed" || review.kind === "stale" ? "close" : "stay";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/manager-recovery.ts
|
|
416
|
+
import { runConfirmation } from "@narumitw/pi-tui-kit";
|
|
417
|
+
|
|
418
|
+
// src/operation-availability.ts
|
|
419
|
+
var DEFAULT_INSPECTION_DEPENDENCIES = {
|
|
420
|
+
inspectMetadata: inspectLock,
|
|
421
|
+
inspectGuard: isLockGuardHeld
|
|
422
|
+
};
|
|
423
|
+
async function inspectOperationAvailability(dependencies = DEFAULT_INSPECTION_DEPENDENCIES) {
|
|
424
|
+
try {
|
|
425
|
+
const metadata = await dependencies.inspectMetadata();
|
|
426
|
+
const guardHeld = await dependencies.inspectGuard();
|
|
427
|
+
return classifyOperationAvailability(metadata, guardHeld);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return { kind: "inspection-error", message: errorMessage(error) };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function classifyOperationAvailability(metadata, guardHeld) {
|
|
433
|
+
if (metadata.status === "valid" && !isStaleLock(metadata.lock)) {
|
|
434
|
+
return { kind: "live", lock: metadata.lock };
|
|
435
|
+
}
|
|
436
|
+
if (guardHeld) {
|
|
437
|
+
return {
|
|
438
|
+
kind: "busy",
|
|
439
|
+
metadata: metadata.status,
|
|
440
|
+
...metadata.status === "valid" ? { lock: metadata.lock } : {}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (metadata.status === "valid") {
|
|
444
|
+
return { kind: "recoverable-stale", lock: metadata.lock };
|
|
445
|
+
}
|
|
446
|
+
if (metadata.status === "unreadable") return { kind: "recoverable-unreadable" };
|
|
447
|
+
return { kind: "free" };
|
|
448
|
+
}
|
|
449
|
+
function operationBlocksChanges(availability) {
|
|
450
|
+
return availability.kind !== "free";
|
|
451
|
+
}
|
|
452
|
+
function operationCanRecover(availability) {
|
|
453
|
+
return availability.kind === "recoverable-stale" || availability.kind === "recoverable-unreadable";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/manager-recovery.ts
|
|
457
|
+
async function recoverSyncAccess(ctx, manager, runRoute, sessionSignal, actionSignal) {
|
|
458
|
+
const operation = manager.operation;
|
|
459
|
+
if (!operation || !operationCanRecover(operation)) {
|
|
460
|
+
ctx.ui.notify(
|
|
461
|
+
"Operation status changed. Refresh the manager before retrying recovery.",
|
|
462
|
+
"warning"
|
|
463
|
+
);
|
|
464
|
+
return "stay";
|
|
465
|
+
}
|
|
466
|
+
const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
|
|
467
|
+
if (signal.aborted) return "close";
|
|
468
|
+
const unreadable = operation.kind === "recoverable-unreadable";
|
|
469
|
+
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.`;
|
|
470
|
+
const confirmation = await runConfirmation(ctx, {
|
|
471
|
+
title: "Restore sync access?",
|
|
472
|
+
message: [
|
|
473
|
+
details,
|
|
474
|
+
"",
|
|
475
|
+
"This removes only the local operation lock.",
|
|
476
|
+
"It does not change settings, local files, sync state, or remote data."
|
|
477
|
+
].join("\n"),
|
|
478
|
+
confirmLabel: "Remove local lock and continue",
|
|
479
|
+
cancelLabel: "Cancel",
|
|
480
|
+
signal,
|
|
481
|
+
isCurrent: () => !signal.aborted,
|
|
482
|
+
onError: (_currentCtx, error) => {
|
|
483
|
+
ctx.ui.notify(
|
|
484
|
+
`Recovery confirmation failed: ${safeTerminalText2(errorMessage(error))}`,
|
|
485
|
+
"error"
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
if (confirmation.kind === "stale") return "close";
|
|
490
|
+
if (confirmation.kind === "closed") {
|
|
491
|
+
if (confirmation.reason === "close") return "close";
|
|
492
|
+
if (!signal.aborted) {
|
|
493
|
+
ctx.ui.notify("Recovery cancelled; the local operation lock was not changed.", "info");
|
|
494
|
+
}
|
|
495
|
+
return "stay";
|
|
496
|
+
}
|
|
497
|
+
if (confirmation.kind !== "confirmed") return "stay";
|
|
498
|
+
if (signal.aborted) return "close";
|
|
499
|
+
await runRoute(unreadable ? "unlock --stale" : "unlock", signal);
|
|
500
|
+
if (signal.aborted) return "close";
|
|
501
|
+
const latest = await inspectOperationAvailability();
|
|
502
|
+
if (signal.aborted) return "close";
|
|
503
|
+
return latest.kind === "free" ? "restored" : "stay";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/manager-state.ts
|
|
507
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
508
|
+
|
|
509
|
+
// src/sync-setups-ui.ts
|
|
510
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
511
|
+
async function countValidSyncSetups(setups, signal) {
|
|
512
|
+
let count = 0;
|
|
513
|
+
for (const name of Object.keys(setups ?? {})) {
|
|
514
|
+
throwIfAborted2(signal);
|
|
515
|
+
let valid = false;
|
|
516
|
+
try {
|
|
517
|
+
await loadConfig(name);
|
|
518
|
+
valid = true;
|
|
519
|
+
} catch {
|
|
520
|
+
}
|
|
521
|
+
throwIfAborted2(signal);
|
|
522
|
+
if (valid) count += 1;
|
|
523
|
+
}
|
|
524
|
+
return count;
|
|
525
|
+
}
|
|
526
|
+
function throwIfAborted2(signal) {
|
|
527
|
+
if (!signal?.aborted) return;
|
|
528
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
529
|
+
}
|
|
530
|
+
async function showSyncSetups(ctx, actions, signal) {
|
|
531
|
+
let selectedName;
|
|
532
|
+
let exit = false;
|
|
533
|
+
const nameById = /* @__PURE__ */ new Map();
|
|
534
|
+
const menu = defineMenu({
|
|
535
|
+
start: "list",
|
|
536
|
+
screens: {
|
|
537
|
+
list: ({ state }) => {
|
|
538
|
+
nameById.clear();
|
|
539
|
+
const names = Object.keys(state.setups).sort((left, right) => left.localeCompare(right));
|
|
540
|
+
return {
|
|
541
|
+
kind: "actions",
|
|
542
|
+
title: "Sync setups",
|
|
543
|
+
items: [
|
|
544
|
+
{ id: "add", label: "Add sync setup", action: "add" },
|
|
545
|
+
...names.map((name, index) => {
|
|
546
|
+
const id = `setup:${index}`;
|
|
547
|
+
nameById.set(id, name);
|
|
548
|
+
return {
|
|
549
|
+
id,
|
|
550
|
+
label: `${safeTerminalText2(name)}${name === state.active ? " (current)" : ""}`,
|
|
551
|
+
action: "select"
|
|
552
|
+
};
|
|
553
|
+
})
|
|
554
|
+
],
|
|
555
|
+
hint: "back"
|
|
556
|
+
};
|
|
557
|
+
},
|
|
558
|
+
detail: ({ state }) => ({
|
|
559
|
+
kind: "actions",
|
|
560
|
+
title: state.selected ? `Sync setup \u201C${safeTerminalText2(state.selected.name)}\u201D` : "Sync setup",
|
|
561
|
+
lines: state.selected?.detail ?? ["This sync setup no longer exists."],
|
|
562
|
+
items: state.selected ? [
|
|
563
|
+
...!state.selected.name || state.selected.name === state.active || !state.selected.valid ? [] : [
|
|
564
|
+
{
|
|
565
|
+
id: "make-current",
|
|
566
|
+
label: "Make current\u2026",
|
|
567
|
+
action: "make-current"
|
|
568
|
+
}
|
|
569
|
+
],
|
|
570
|
+
{ id: "edit", label: "Edit sync setup\u2026", action: "edit" },
|
|
571
|
+
...state.selected.removeUnavailable ? [] : [
|
|
572
|
+
{
|
|
573
|
+
id: "remove",
|
|
574
|
+
label: "Remove sync setup\u2026",
|
|
575
|
+
action: "remove"
|
|
576
|
+
}
|
|
577
|
+
],
|
|
578
|
+
{ id: "back", label: "Back", action: "back" }
|
|
579
|
+
] : [{ id: "back", label: "Back", action: "back" }],
|
|
580
|
+
hint: "back"
|
|
581
|
+
})
|
|
582
|
+
},
|
|
583
|
+
actions: {
|
|
584
|
+
add: async () => {
|
|
585
|
+
try {
|
|
586
|
+
await actions.add(signal);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (!signal?.aborted) {
|
|
589
|
+
ctx.ui.notify(
|
|
590
|
+
`Sync setup was not added: ${menuErrorMessage(error)} Retry from Add sync setup.`,
|
|
591
|
+
"error"
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { kind: "stay" };
|
|
596
|
+
},
|
|
597
|
+
select: async ({ itemId }) => {
|
|
598
|
+
selectedName = nameById.get(itemId);
|
|
599
|
+
return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" };
|
|
600
|
+
},
|
|
601
|
+
"make-current": async () => {
|
|
602
|
+
if (!selectedName) return { kind: "rejected" };
|
|
603
|
+
try {
|
|
604
|
+
exit = await actions.makeCurrent(selectedName, signal) === "exit";
|
|
605
|
+
return exit ? { kind: "close" } : { kind: "stay" };
|
|
606
|
+
} catch (error) {
|
|
607
|
+
notifySetupChangeError(ctx, selectedName, error, signal);
|
|
608
|
+
return { kind: "stay" };
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
edit: async () => {
|
|
612
|
+
if (!selectedName) return { kind: "rejected" };
|
|
613
|
+
try {
|
|
614
|
+
await actions.edit(selectedName, signal);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
notifySetupChangeError(ctx, selectedName, error, signal);
|
|
617
|
+
}
|
|
618
|
+
return { kind: "stay" };
|
|
619
|
+
},
|
|
620
|
+
remove: async () => {
|
|
621
|
+
if (!selectedName) return { kind: "rejected" };
|
|
622
|
+
const name = selectedName;
|
|
623
|
+
try {
|
|
624
|
+
await actions.remove(name, signal);
|
|
625
|
+
selectedName = void 0;
|
|
626
|
+
return { kind: "back" };
|
|
627
|
+
} catch (error) {
|
|
628
|
+
notifySetupChangeError(ctx, name, error, signal);
|
|
629
|
+
return { kind: "stay" };
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
back: async () => {
|
|
633
|
+
selectedName = void 0;
|
|
634
|
+
return { kind: "back" };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
await runMenu(ctx, menu, {
|
|
639
|
+
getState: async () => loadSetupMenuState(selectedName, signal),
|
|
640
|
+
signal,
|
|
641
|
+
isCurrent: () => !signal?.aborted
|
|
642
|
+
});
|
|
643
|
+
return exit ? "exit" : void 0;
|
|
644
|
+
}
|
|
645
|
+
async function loadSetupMenuState(selectedName, signal) {
|
|
646
|
+
const raw = await readLocalConfigObject();
|
|
647
|
+
throwIfAborted2(signal);
|
|
648
|
+
const setups = ownRecord(raw?.syncSetups) ?? {};
|
|
649
|
+
const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : void 0;
|
|
650
|
+
if (!selectedName || !ownRecord(setups[selectedName])) return { setups, active };
|
|
651
|
+
const setupCount = Object.keys(setups).length;
|
|
652
|
+
const isCurrent = selectedName === active;
|
|
653
|
+
let detail;
|
|
654
|
+
let valid = true;
|
|
655
|
+
try {
|
|
656
|
+
const config = await loadConfig(selectedName);
|
|
657
|
+
throwIfAborted2(signal);
|
|
658
|
+
const selection = syncIncludeSelection(config.include);
|
|
659
|
+
detail = [
|
|
660
|
+
`Status: ${isCurrent ? "Current" : "Not current"}`,
|
|
661
|
+
`Storage connection: ${safeTerminalText2(config.connectionName)}`,
|
|
662
|
+
`Endpoint: ${storageEndpoint(config)}`,
|
|
663
|
+
`Storage location: ${storageLocation(config)}`,
|
|
664
|
+
`Included content: ${selection.builtIns.length} built-in groups \xB7 ${selection.custom.length} extra paths`,
|
|
665
|
+
`Sessions: ${selection.sessions ? "On \u2014 privacy-sensitive" : "Off"}`,
|
|
666
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`
|
|
667
|
+
];
|
|
668
|
+
} catch (error) {
|
|
669
|
+
valid = false;
|
|
670
|
+
detail = [
|
|
671
|
+
`Status: Invalid${isCurrent ? " current setup" : ""}`,
|
|
672
|
+
`Reason: ${menuErrorMessage(error)}`,
|
|
673
|
+
"Make current and sync are unavailable until this setup is repaired."
|
|
674
|
+
];
|
|
675
|
+
}
|
|
676
|
+
const removeUnavailable = isCurrent && setupCount > 1;
|
|
677
|
+
if (removeUnavailable) detail.push("Remove unavailable: switch to another setup first.");
|
|
678
|
+
return {
|
|
679
|
+
setups,
|
|
680
|
+
active,
|
|
681
|
+
selected: { name: selectedName, detail, valid, removeUnavailable }
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function notifySetupChangeError(ctx, name, error, signal) {
|
|
685
|
+
if (signal?.aborted) return;
|
|
686
|
+
ctx.ui.notify(
|
|
687
|
+
`Sync setup \u201C${safeTerminalText2(name)}\u201D was not changed: ${menuErrorMessage(error)} Reopen it and retry.`,
|
|
688
|
+
"error"
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
function menuErrorMessage(error) {
|
|
692
|
+
return safeTerminalText2(errorMessage(error));
|
|
693
|
+
}
|
|
694
|
+
function storageEndpoint(config) {
|
|
695
|
+
switch (config.backend.type) {
|
|
696
|
+
case "s3":
|
|
697
|
+
return safeTerminalText2(config.backend.profile.endpoint);
|
|
698
|
+
case "git":
|
|
699
|
+
return safeTerminalText2(config.backend.profile.remote);
|
|
700
|
+
case "webdav":
|
|
701
|
+
return safeTerminalText2(config.backend.profile.url);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function storageLocation(config) {
|
|
705
|
+
switch (config.backend.type) {
|
|
706
|
+
case "s3":
|
|
707
|
+
return safeTerminalText2(`${config.backend.destination.bucket}/${config.storagePath}`);
|
|
708
|
+
case "git":
|
|
709
|
+
return safeTerminalText2(`Git \xB7 ${config.backend.destination.branch}:${config.storagePath}`);
|
|
710
|
+
case "webdav":
|
|
711
|
+
return safeTerminalText2(`WebDAV \xB7 ${config.storagePath}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/manager-state.ts
|
|
716
|
+
var MAIN_MENU_ACTIONS = [
|
|
717
|
+
"Sync now (recommended)",
|
|
718
|
+
"Switch sync setup",
|
|
719
|
+
"Status & changes",
|
|
720
|
+
"Settings",
|
|
721
|
+
"More\u2026"
|
|
722
|
+
];
|
|
723
|
+
async function describeManagerState(signal, attention, inspectOperation = inspectOperationAvailability) {
|
|
724
|
+
let raw;
|
|
725
|
+
try {
|
|
726
|
+
raw = await readLocalConfigObject();
|
|
727
|
+
} catch (error) {
|
|
728
|
+
return {
|
|
729
|
+
title: [
|
|
730
|
+
"Manage sync",
|
|
731
|
+
"",
|
|
732
|
+
"Settings file needs repair. Automatic sync and settings writes are paused.",
|
|
733
|
+
`Error: ${safeTerminalText2(errorMessage(error))}`,
|
|
734
|
+
`File: ${safeTerminalText2(await activeLocalConfigPath())}`,
|
|
735
|
+
"",
|
|
736
|
+
"Repair the JSON file, then reopen /sync."
|
|
737
|
+
].join("\n"),
|
|
738
|
+
actions: ["Help"]
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (!raw) {
|
|
742
|
+
return {
|
|
743
|
+
title: ["Manage sync", "", "Not set up.", "", "What do you want to do?"].join("\n"),
|
|
744
|
+
actions: ["Set up sync", "Help"]
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const configuredTargets = ownRecord(raw.syncSetups);
|
|
748
|
+
if (raw.version === 3 && configuredTargets && Object.keys(configuredTargets).length === 0) {
|
|
749
|
+
return {
|
|
750
|
+
title: [
|
|
751
|
+
"Manage sync",
|
|
752
|
+
"",
|
|
753
|
+
"No sync setups are configured.",
|
|
754
|
+
"Add a sync setup using an existing storage connection.",
|
|
755
|
+
"",
|
|
756
|
+
"What do you want to do?"
|
|
757
|
+
].join("\n"),
|
|
758
|
+
actions: ["Sync setups\u2026", "Storage connections\u2026", "Help"]
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
const config = await loadConfig();
|
|
763
|
+
const operation = await inspectOperation();
|
|
764
|
+
const changesBlocked = operationBlocksChanges(operation);
|
|
765
|
+
const selection = syncIncludeSelection(config.include);
|
|
766
|
+
let currentAttention;
|
|
767
|
+
if (attention) {
|
|
768
|
+
try {
|
|
769
|
+
const attentionConfig = attention.decision.setupName === config.setupName ? config : await loadConfig(attention.decision.setupName);
|
|
770
|
+
if (syncAttentionMatchesConfig(attention, attentionConfig)) currentAttention = attention;
|
|
771
|
+
} catch {
|
|
772
|
+
currentAttention = void 0;
|
|
773
|
+
}
|
|
774
|
+
if (signal?.aborted) throw signal.reason;
|
|
775
|
+
}
|
|
776
|
+
const attentionComparison = currentAttention ? compareSyncInclude(
|
|
777
|
+
currentAttention.decision.localInclude,
|
|
778
|
+
currentAttention.decision.remoteInclude
|
|
779
|
+
) : void 0;
|
|
780
|
+
const noSyncedContent = config.include.length === 0;
|
|
781
|
+
const syncState = changesBlocked ? void 0 : await readStateForConfig(config).catch(() => void 0);
|
|
782
|
+
const lastAppliedSnapshot = changesBlocked ? "Unavailable while operations are locked" : syncState?.lastAppliedSnapshot ? safeTerminalText2(syncState.lastAppliedSnapshot) : syncState ? "Never synced" : "Unavailable";
|
|
783
|
+
const canSwitch = await countValidSyncSetups(configuredTargets, signal) > 1;
|
|
784
|
+
const mainActions = MAIN_MENU_ACTIONS.filter(
|
|
785
|
+
(action) => action !== "Switch sync setup" || canSwitch
|
|
786
|
+
);
|
|
787
|
+
const ordinaryTitle = [
|
|
788
|
+
"Manage sync",
|
|
789
|
+
"",
|
|
790
|
+
`Current sync setup: ${safeTerminalText2(config.setupName)}`,
|
|
791
|
+
`Storage: ${backendStorageDescription(config)}`,
|
|
792
|
+
`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"}`,
|
|
793
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
794
|
+
`Last applied: ${lastAppliedSnapshot}`,
|
|
795
|
+
...currentAttention ? [
|
|
796
|
+
currentAttention.decision.setupName === config.setupName ? "Sync status: Review needed" : `Sync status: Review needed for setup ${safeTerminalText2(currentAttention.decision.setupName)}`,
|
|
797
|
+
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}`,
|
|
798
|
+
"Nothing has been changed."
|
|
799
|
+
] : ["Remote status: Not checked"],
|
|
800
|
+
...noSyncedContent ? [
|
|
801
|
+
"",
|
|
802
|
+
"No included content is selected. Choose included content in Settings before syncing."
|
|
803
|
+
] : [],
|
|
804
|
+
"",
|
|
805
|
+
"What do you want to do?"
|
|
806
|
+
];
|
|
807
|
+
return {
|
|
808
|
+
title: (changesBlocked ? ["Manage sync", ...operationStatusLines(operation)] : ordinaryTitle).join("\n"),
|
|
809
|
+
actions: operationActions(operation, noSyncedContent, canSwitch, mainActions),
|
|
810
|
+
operation,
|
|
811
|
+
...currentAttention ? {
|
|
812
|
+
attention: currentAttention,
|
|
813
|
+
attentionBlocksSync: currentAttention.decision.setupName === config.setupName,
|
|
814
|
+
attentionReviewDisabled: changesBlocked
|
|
815
|
+
} : {}
|
|
816
|
+
};
|
|
817
|
+
} catch (error) {
|
|
818
|
+
if (signal?.aborted) throw error;
|
|
819
|
+
return {
|
|
820
|
+
title: [
|
|
821
|
+
"Manage sync",
|
|
822
|
+
"",
|
|
823
|
+
"Settings need attention. Automatic sync is paused.",
|
|
824
|
+
`Current sync setup: ${safeTerminalText2(typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : "none")}`,
|
|
825
|
+
`Error: ${safeTerminalText2(errorMessage(error))}`,
|
|
826
|
+
`File: ${safeTerminalText2(await activeLocalConfigPath())}`,
|
|
827
|
+
"",
|
|
828
|
+
"What do you want to do?"
|
|
829
|
+
].join("\n"),
|
|
830
|
+
actions: ["Sync setups\u2026", "Storage connections\u2026", "History & recovery\u2026", "Help"]
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function operationActions(operation, noSyncedContent, canSwitch, mainActions) {
|
|
835
|
+
if (operationCanRecover(operation)) {
|
|
836
|
+
return [
|
|
837
|
+
"Restore sync access\u2026 (recommended)",
|
|
838
|
+
"Status & changes",
|
|
839
|
+
"History & recovery\u2026",
|
|
840
|
+
"Help"
|
|
841
|
+
];
|
|
842
|
+
}
|
|
843
|
+
if (operation.kind !== "free") {
|
|
844
|
+
return ["Refresh operation status", "Status & changes", "History & recovery\u2026", "Help"];
|
|
845
|
+
}
|
|
846
|
+
return noSyncedContent ? ["Settings", ...canSwitch ? ["Switch sync setup"] : [], "Status & changes", "More\u2026"] : mainActions;
|
|
847
|
+
}
|
|
848
|
+
function operationStatusLines(operation) {
|
|
849
|
+
switch (operation.kind) {
|
|
850
|
+
case "free":
|
|
851
|
+
return [];
|
|
852
|
+
case "live": {
|
|
853
|
+
const command = truncateToWidth(safeTerminalText2(operation.lock.command), 16, "\u2026");
|
|
854
|
+
return [
|
|
855
|
+
`Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Settings and More return.`
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
case "busy":
|
|
859
|
+
return [
|
|
860
|
+
"Pi-sync may be starting or finishing. Wait, then refresh; Settings and More remain unavailable."
|
|
861
|
+
];
|
|
862
|
+
case "recoverable-stale":
|
|
863
|
+
return [
|
|
864
|
+
"Sync paused: old lock remains. Close other Pi sessions then restore; Settings and More return."
|
|
865
|
+
];
|
|
866
|
+
case "recoverable-unreadable":
|
|
867
|
+
return [
|
|
868
|
+
"Sync paused: owner unknown. Close other Pi sessions then restore; Settings and More return."
|
|
869
|
+
];
|
|
870
|
+
case "inspection-error":
|
|
871
|
+
return [
|
|
872
|
+
"Lock check failed. Fix path access, then refresh; Settings and More remain unavailable."
|
|
873
|
+
];
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function backendStorageDescription(config) {
|
|
877
|
+
const connection = safeTerminalText2(config.connectionName);
|
|
878
|
+
switch (config.backend.type) {
|
|
879
|
+
case "s3": {
|
|
880
|
+
const type = config.backend.profile.kind === "r2" || isCloudflareR2Endpoint(config.backend.profile.endpoint) ? "Cloudflare R2" : "S3-compatible";
|
|
881
|
+
return `${type} \xB7 ${connection} \xB7 ${safeTerminalText2(config.backend.destination.bucket)}`;
|
|
882
|
+
}
|
|
883
|
+
case "webdav":
|
|
884
|
+
return `WebDAV \xB7 ${connection} \xB7 ${safeTerminalText2(config.backend.destination.path)}`;
|
|
885
|
+
case "git":
|
|
886
|
+
return `Git \xB7 ${connection} \xB7 ${safeTerminalText2(config.backend.destination.branch)}`;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/secret-input.ts
|
|
891
|
+
import {
|
|
892
|
+
CURSOR_MARKER,
|
|
893
|
+
decodeKittyPrintable,
|
|
894
|
+
Text,
|
|
895
|
+
truncateToWidth as truncateToWidth2
|
|
896
|
+
} from "@earendil-works/pi-tui";
|
|
897
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
898
|
+
var MASK = "\u2022";
|
|
899
|
+
async function promptSecret(ctx, title, options = { required: true }) {
|
|
900
|
+
if (ctx.mode !== "tui" || options.signal?.aborted) return void 0;
|
|
901
|
+
const result = await runCustomInteraction(ctx, {
|
|
902
|
+
signal: options.signal,
|
|
903
|
+
isCurrent: () => !options.signal?.aborted,
|
|
904
|
+
create: ({ tui, theme, keybindings, complete }) => {
|
|
905
|
+
const heading = new Text("", 0, 0);
|
|
906
|
+
const hint = new Text("", 0, 0);
|
|
907
|
+
const submitKey = keybindingText(keybindings, "tui.input.submit", "enter");
|
|
908
|
+
const cancelKey = keybindingText(keybindings, "tui.select.cancel", "esc");
|
|
909
|
+
const applyTheme = () => {
|
|
910
|
+
heading.setText(theme.fg("accent", theme.bold(title)));
|
|
911
|
+
hint.setText(theme.fg("dim", `${submitKey} save \u2022 ${cancelKey} cancel \u2022 Input is hidden`));
|
|
912
|
+
};
|
|
913
|
+
applyTheme();
|
|
914
|
+
const input = new MaskedInput(keybindings);
|
|
915
|
+
const component = {
|
|
916
|
+
get focused() {
|
|
917
|
+
return input.focused;
|
|
918
|
+
},
|
|
919
|
+
set focused(focused) {
|
|
920
|
+
input.focused = focused;
|
|
921
|
+
},
|
|
922
|
+
render(width) {
|
|
923
|
+
const safeWidth = Math.max(1, width);
|
|
924
|
+
return [
|
|
925
|
+
...heading.render(safeWidth),
|
|
926
|
+
...input.render(safeWidth),
|
|
927
|
+
...hint.render(safeWidth)
|
|
928
|
+
].map((line) => truncateToWidth2(line, safeWidth));
|
|
929
|
+
},
|
|
930
|
+
invalidate() {
|
|
931
|
+
applyTheme();
|
|
932
|
+
heading.invalidate();
|
|
933
|
+
input.invalidate();
|
|
934
|
+
hint.invalidate();
|
|
935
|
+
},
|
|
936
|
+
handleInput(data) {
|
|
937
|
+
if (keybindings.matches(data, "tui.select.cancel")) complete(void 0);
|
|
938
|
+
else if (keybindings.matches(data, "tui.input.submit")) complete(input.getValue());
|
|
939
|
+
else input.handleInput(data);
|
|
940
|
+
tui.requestRender();
|
|
941
|
+
},
|
|
942
|
+
dispose() {
|
|
943
|
+
input.clear();
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
return component;
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
if (result.kind === "error") throw result.error;
|
|
950
|
+
if (result.kind !== "completed" || result.value === void 0) return void 0;
|
|
951
|
+
const value = result.value;
|
|
952
|
+
if (options.required !== false && value.length === 0) {
|
|
953
|
+
ctx.ui.notify(`${title} is required.`, "warning");
|
|
954
|
+
return void 0;
|
|
955
|
+
}
|
|
956
|
+
return value;
|
|
957
|
+
}
|
|
958
|
+
var MaskedInput = class {
|
|
959
|
+
constructor(keybindings) {
|
|
960
|
+
this.keybindings = keybindings;
|
|
961
|
+
}
|
|
962
|
+
keybindings;
|
|
963
|
+
focused = false;
|
|
964
|
+
value = [];
|
|
965
|
+
cursor = 0;
|
|
966
|
+
paste = "";
|
|
967
|
+
pasting = false;
|
|
968
|
+
getValue() {
|
|
969
|
+
return this.value.join("");
|
|
970
|
+
}
|
|
971
|
+
handleInput(data) {
|
|
972
|
+
if (data.includes("\x1B[200~")) {
|
|
973
|
+
this.pasting = true;
|
|
974
|
+
this.paste = "";
|
|
975
|
+
data = data.replace("\x1B[200~", "");
|
|
976
|
+
}
|
|
977
|
+
if (this.pasting) {
|
|
978
|
+
this.paste += data;
|
|
979
|
+
const end = this.paste.indexOf("\x1B[201~");
|
|
980
|
+
if (end < 0) return;
|
|
981
|
+
const pasted = this.paste.slice(0, end).replace(/[\r\n]/gu, "").replace(/\t/gu, " ");
|
|
982
|
+
this.insert(pasted);
|
|
983
|
+
const remaining = this.paste.slice(end + 6);
|
|
984
|
+
this.paste = "";
|
|
985
|
+
this.pasting = false;
|
|
986
|
+
if (remaining) this.handleInput(remaining);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
|
|
990
|
+
if (this.cursor > 0) this.value.splice(--this.cursor, 1);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharForward")) {
|
|
994
|
+
if (this.cursor < this.value.length) this.value.splice(this.cursor, 1);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (this.keybindings.matches(data, "tui.editor.cursorLeft")) {
|
|
998
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (this.keybindings.matches(data, "tui.editor.cursorRight")) {
|
|
1002
|
+
this.cursor = Math.min(this.value.length, this.cursor + 1);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (this.keybindings.matches(data, "tui.editor.cursorLineStart")) {
|
|
1006
|
+
this.cursor = 0;
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (this.keybindings.matches(data, "tui.editor.cursorLineEnd")) {
|
|
1010
|
+
this.cursor = this.value.length;
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (this.keybindings.matches(data, "tui.editor.deleteToLineStart")) {
|
|
1014
|
+
this.value.splice(0, this.cursor);
|
|
1015
|
+
this.cursor = 0;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (this.keybindings.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
1019
|
+
this.value.splice(this.cursor);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const printable = decodeKittyPrintable(data) ?? data;
|
|
1023
|
+
if (!hasControlCharacter(printable)) this.insert(printable);
|
|
1024
|
+
}
|
|
1025
|
+
render(width) {
|
|
1026
|
+
const prompt = "> ";
|
|
1027
|
+
const available = width - prompt.length;
|
|
1028
|
+
if (available <= 0) return [truncateToWidth2(prompt, Math.max(1, width))];
|
|
1029
|
+
const contentWidth = Math.max(0, available - 1);
|
|
1030
|
+
let start = 0;
|
|
1031
|
+
if (this.value.length > contentWidth) {
|
|
1032
|
+
start = Math.max(
|
|
1033
|
+
0,
|
|
1034
|
+
Math.min(this.cursor - Math.floor(contentWidth / 2), this.value.length - contentWidth)
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
const end = Math.min(this.value.length, start + contentWidth);
|
|
1038
|
+
const visibleCursor = Math.max(0, Math.min(this.cursor - start, end - start));
|
|
1039
|
+
const masks = Array.from({ length: end - start }, () => MASK);
|
|
1040
|
+
const before = masks.slice(0, visibleCursor).join("");
|
|
1041
|
+
const atCursor = visibleCursor < masks.length ? MASK : " ";
|
|
1042
|
+
const after = masks.slice(visibleCursor + (visibleCursor < masks.length ? 1 : 0)).join("");
|
|
1043
|
+
const marker = this.focused ? CURSOR_MARKER : "";
|
|
1044
|
+
const line = `${prompt}${before}${marker}\x1B[7m${atCursor}\x1B[27m${after}`;
|
|
1045
|
+
return [truncateToWidth2(line, width, "")];
|
|
1046
|
+
}
|
|
1047
|
+
invalidate() {
|
|
1048
|
+
}
|
|
1049
|
+
clear() {
|
|
1050
|
+
this.value.fill("");
|
|
1051
|
+
this.value = [];
|
|
1052
|
+
this.paste = "";
|
|
1053
|
+
this.cursor = 0;
|
|
1054
|
+
this.pasting = false;
|
|
1055
|
+
}
|
|
1056
|
+
insert(value) {
|
|
1057
|
+
const characters = Array.from(value);
|
|
1058
|
+
this.value.splice(this.cursor, 0, ...characters);
|
|
1059
|
+
this.cursor += characters.length;
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
function keybindingText(keybindings, binding, fallback) {
|
|
1063
|
+
const keys = keybindings.getKeys(binding).map(String).map((key) => {
|
|
1064
|
+
if (key === "return") return "enter";
|
|
1065
|
+
if (key === "escape") return "esc";
|
|
1066
|
+
return hasControlCharacter(key) ? "" : key;
|
|
1067
|
+
}).filter(Boolean);
|
|
1068
|
+
return keys.join("/") || fallback;
|
|
1069
|
+
}
|
|
1070
|
+
function hasControlCharacter(value) {
|
|
1071
|
+
return [...value].some((character) => {
|
|
1072
|
+
const code = character.codePointAt(0) ?? 0;
|
|
1073
|
+
return code < 32 || code >= 127 && code <= 159;
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/s3-credentials-ui.ts
|
|
1078
|
+
async function chooseS3CredentialUpdate(ctx, profile, signal) {
|
|
1079
|
+
const hasStored = typeof profile.accessKeyId === "string" && typeof profile.secretAccessKey === "string";
|
|
1080
|
+
if (hasStored) {
|
|
1081
|
+
const action = await ctx.ui.select(
|
|
1082
|
+
"Credentials",
|
|
1083
|
+
["Keep current credentials", "Change credential source", "Cancel"],
|
|
1084
|
+
{ signal }
|
|
1085
|
+
);
|
|
1086
|
+
throwIfAborted3(signal);
|
|
1087
|
+
if (!action || action === "Cancel") return void 0;
|
|
1088
|
+
if (action === "Keep current credentials") {
|
|
1089
|
+
return { profileFields: {}, summary: "Unchanged (values hidden)", ready: true };
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const selected = await chooseS3Credentials(ctx, signal);
|
|
1093
|
+
return selected ? { ...selected, replace: true } : void 0;
|
|
1094
|
+
}
|
|
1095
|
+
function applyS3CredentialUpdate(profile, credentials) {
|
|
1096
|
+
const next = { ...profile };
|
|
1097
|
+
if (credentials.replace) {
|
|
1098
|
+
delete next.accessKeyId;
|
|
1099
|
+
delete next.secretAccessKey;
|
|
1100
|
+
delete next.sessionToken;
|
|
1101
|
+
}
|
|
1102
|
+
return { ...next, ...credentials.profileFields };
|
|
1103
|
+
}
|
|
1104
|
+
async function chooseS3Credentials(ctx, signal) {
|
|
1105
|
+
const choice = await ctx.ui.select(
|
|
1106
|
+
"Credentials\n\nCredentials are stored in the private pi-sync settings file. Secret values are masked during input and never shown afterward.",
|
|
1107
|
+
["Store credentials privately", "Cancel"],
|
|
1108
|
+
{ signal }
|
|
1109
|
+
);
|
|
1110
|
+
throwIfAborted3(signal);
|
|
1111
|
+
if (choice !== "Store credentials privately") return void 0;
|
|
1112
|
+
const accessKeyId = await requiredCredentialInput(ctx, "Access key ID", "access-key-id", signal);
|
|
1113
|
+
if (!accessKeyId) return void 0;
|
|
1114
|
+
const secretAccessKey = await promptSecret(ctx, "Secret access key", { signal });
|
|
1115
|
+
throwIfAborted3(signal);
|
|
1116
|
+
if (secretAccessKey === void 0) return void 0;
|
|
1117
|
+
return {
|
|
1118
|
+
profileFields: { accessKeyId, secretAccessKey },
|
|
1119
|
+
summary: "Stored privately (values hidden)",
|
|
1120
|
+
ready: true
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
async function requiredCredentialInput(ctx, title, placeholder, signal) {
|
|
1124
|
+
const value = await ctx.ui.input(title, placeholder, { signal });
|
|
1125
|
+
throwIfAborted3(signal);
|
|
1126
|
+
if (value === void 0) return void 0;
|
|
1127
|
+
const normalized = value.trim();
|
|
1128
|
+
if (!normalized) {
|
|
1129
|
+
ctx.ui.notify(`${title} is required.`, "warning");
|
|
1130
|
+
return void 0;
|
|
1131
|
+
}
|
|
1132
|
+
return normalized.includes("<") || normalized.includes(">") ? void 0 : normalized;
|
|
1133
|
+
}
|
|
1134
|
+
function throwIfAborted3(signal) {
|
|
1135
|
+
if (!signal?.aborted) return;
|
|
1136
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/settings-ui.ts
|
|
1140
|
+
import { defineMenu as defineMenu2, runMenu as runMenu2 } from "@narumitw/pi-tui-kit";
|
|
1141
|
+
async function showSyncSettings(ctx, runRoute, signal) {
|
|
1142
|
+
if (ctx.mode !== "tui") {
|
|
1143
|
+
ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const initial = await loadConfig();
|
|
1147
|
+
if (signal?.aborted) return;
|
|
1148
|
+
const setupName = initial.setupName;
|
|
1149
|
+
const menu = defineMenu2({
|
|
1150
|
+
start: "settings",
|
|
1151
|
+
screens: {
|
|
1152
|
+
settings: ({ state }) => ({
|
|
1153
|
+
kind: "settings",
|
|
1154
|
+
title: "Pi Sync Settings",
|
|
1155
|
+
lines: [
|
|
1156
|
+
`Sync setup: ${safeTerminalText(state.setupName)} \xB7 Storage connection: ${safeTerminalText(state.connectionName)}`
|
|
1157
|
+
],
|
|
1158
|
+
items: [
|
|
1159
|
+
{
|
|
1160
|
+
id: "automatic",
|
|
1161
|
+
label: "Automatic sync",
|
|
1162
|
+
description: "Run conservative synchronization at session startup and shutdown.",
|
|
1163
|
+
currentValue: state.automatic ? "On" : "Off",
|
|
1164
|
+
values: ["On", "Off"],
|
|
1165
|
+
action: "automatic"
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
id: "onSwitch",
|
|
1169
|
+
label: "After switching setup",
|
|
1170
|
+
description: "Ask before a reviewed pull, start a reviewed pull, or switch without checking remote files.",
|
|
1171
|
+
currentValue: setupSwitchActionLabel(state.onSwitch),
|
|
1172
|
+
values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
|
|
1173
|
+
action: "on-switch"
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
id: "include",
|
|
1177
|
+
label: "Included content",
|
|
1178
|
+
description: `${state.include.length} selected path${state.include.length === 1 ? "" : "s"}. Opens the reviewed content-selection draft.`,
|
|
1179
|
+
currentValue: "Open editor",
|
|
1180
|
+
action: "include"
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
id: "remoteInclude",
|
|
1184
|
+
label: "Compare synced content",
|
|
1185
|
+
description: "Review this device and remote content lists before choosing either one.",
|
|
1186
|
+
currentValue: "Review",
|
|
1187
|
+
action: "remote-include"
|
|
1188
|
+
}
|
|
1189
|
+
]
|
|
1190
|
+
})
|
|
1191
|
+
},
|
|
1192
|
+
actions: {
|
|
1193
|
+
automatic: async ({ value, signal: actionSignal }) => {
|
|
1194
|
+
const automatic = value === "On";
|
|
1195
|
+
const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
1196
|
+
try {
|
|
1197
|
+
const latest = await loadConfig(setupName);
|
|
1198
|
+
if (mutationSignal.aborted) return { kind: "rejected" };
|
|
1199
|
+
if (latest.automatic === automatic) return { kind: "stay" };
|
|
1200
|
+
await updateSyncSetup(
|
|
1201
|
+
setupName,
|
|
1202
|
+
(setup) => ({ ...setup, sync: { ...setup.sync, automatic } }),
|
|
1203
|
+
{ signal: mutationSignal }
|
|
1204
|
+
);
|
|
1205
|
+
if (mutationSignal.aborted) return { kind: "rejected" };
|
|
1206
|
+
ctx.ui.notify(
|
|
1207
|
+
`Automatic sync ${automatic ? "enabled" : "disabled"} for \u201C${safeTerminalText(setupName)}\u201D.`,
|
|
1208
|
+
"info"
|
|
1209
|
+
);
|
|
1210
|
+
return { kind: "stay" };
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
|
|
1213
|
+
return { kind: "rejected" };
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
"on-switch": async ({ value, signal: actionSignal }) => {
|
|
1217
|
+
const action = value ? setupSwitchActionFromLabel(value) : void 0;
|
|
1218
|
+
if (!action) return { kind: "rejected" };
|
|
1219
|
+
const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
1220
|
+
try {
|
|
1221
|
+
const latest = await loadConfig(setupName);
|
|
1222
|
+
if (mutationSignal.aborted) return { kind: "rejected" };
|
|
1223
|
+
if (latest.onSwitch === action) return { kind: "stay" };
|
|
1224
|
+
await saveOnSwitch(action, mutationSignal);
|
|
1225
|
+
if (mutationSignal.aborted) return { kind: "rejected" };
|
|
1226
|
+
ctx.ui.notify(`After switching setup: ${value}.`, "info");
|
|
1227
|
+
return { kind: "stay" };
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
|
|
1230
|
+
return { kind: "rejected" };
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
include: async ({ signal: actionSignal }) => {
|
|
1234
|
+
const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
1235
|
+
await runRoute("files", editorSignal, void 0, setupName);
|
|
1236
|
+
return editorSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
|
|
1237
|
+
},
|
|
1238
|
+
"remote-include": async ({ signal: actionSignal }) => {
|
|
1239
|
+
const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
1240
|
+
const { showRemoteSelectionReview } = await import("./remote-selection-ui-J6OW5DKZ.js");
|
|
1241
|
+
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
1242
|
+
const review = await showRemoteSelectionReview(ctx, setupName, reviewSignal, void 0, {
|
|
1243
|
+
origin: "settings",
|
|
1244
|
+
runRoute
|
|
1245
|
+
});
|
|
1246
|
+
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
1247
|
+
if (review.kind === "route-result") {
|
|
1248
|
+
const disposition = await dispatchManagerResult(
|
|
1249
|
+
ctx,
|
|
1250
|
+
review.result,
|
|
1251
|
+
review.route,
|
|
1252
|
+
runRoute,
|
|
1253
|
+
reviewSignal
|
|
1254
|
+
);
|
|
1255
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
1256
|
+
}
|
|
1257
|
+
return review.kind === "closed" || review.kind === "stale" ? { kind: "close" } : { kind: "stay" };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
await runMenu2(ctx, menu, {
|
|
1262
|
+
getState: () => loadConfig(setupName),
|
|
1263
|
+
signal,
|
|
1264
|
+
isCurrent: () => !signal?.aborted
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
function notifySaveFailure(ctx, error) {
|
|
1268
|
+
ctx.ui.notify(
|
|
1269
|
+
`Pi Sync settings save failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1270
|
+
"error"
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// src/storage-connections-ui.ts
|
|
1275
|
+
import { defineMenu as defineMenu3, runMenu as runMenu3 } from "@narumitw/pi-tui-kit";
|
|
1276
|
+
|
|
1277
|
+
// src/webdav-ui.ts
|
|
1278
|
+
async function showWebDavSetup(ctx, targetName, signal) {
|
|
1279
|
+
const url = await requiredInput2(
|
|
1280
|
+
ctx,
|
|
1281
|
+
"WebDAV collection URL",
|
|
1282
|
+
"https://cloud.example.com/remote.php/dav/files/user",
|
|
1283
|
+
signal
|
|
1284
|
+
);
|
|
1285
|
+
if (!url) return false;
|
|
1286
|
+
const username = await requiredInput2(ctx, "WebDAV username", "user", signal);
|
|
1287
|
+
if (!username) return false;
|
|
1288
|
+
const password = await awaitActive(signal, promptSecret(ctx, "WebDAV password", { signal }));
|
|
1289
|
+
if (password === void 0) return false;
|
|
1290
|
+
const location = await chooseDestination(ctx, targetName, signal);
|
|
1291
|
+
if (!location) return false;
|
|
1292
|
+
const connection = validateConnection(ctx, url, username, password);
|
|
1293
|
+
const destination = validateDestination(ctx, location.path);
|
|
1294
|
+
if (!connection || !destination) return false;
|
|
1295
|
+
const content = await chooseContent(ctx, signal);
|
|
1296
|
+
if (!content) return false;
|
|
1297
|
+
const automatic = await select(
|
|
1298
|
+
ctx,
|
|
1299
|
+
"Automatic sync for this setup",
|
|
1300
|
+
["Enable automatic sync", "Keep automatic sync off", "Cancel"],
|
|
1301
|
+
signal
|
|
1302
|
+
);
|
|
1303
|
+
if (!automatic || automatic === "Cancel") return false;
|
|
1304
|
+
const sessions = await chooseSessions(ctx, signal);
|
|
1305
|
+
if (sessions === void 0) return false;
|
|
1306
|
+
const profileName = "webdav";
|
|
1307
|
+
const review = await select(
|
|
1308
|
+
ctx,
|
|
1309
|
+
[
|
|
1310
|
+
"Review WebDAV setup",
|
|
1311
|
+
"",
|
|
1312
|
+
`Sync setup: ${safe(targetName)}`,
|
|
1313
|
+
`Storage connection: ${profileName} (WebDAV)`,
|
|
1314
|
+
`URL: ${displayUrl(connection.url)}`,
|
|
1315
|
+
`Storage location: ${safe(destination.path)}`,
|
|
1316
|
+
"Username: stored in the private settings file (value hidden)",
|
|
1317
|
+
"Password: configured (value hidden)",
|
|
1318
|
+
`Conditional writes: /sync doctor verifies atomic If-Match and If-None-Match support before publication.`,
|
|
1319
|
+
`Included content: ${content.length} built-in groups \xB7 Sessions: ${sessions ? "On \u2014 privacy warning acknowledged" : "Off"}`,
|
|
1320
|
+
`Automatic sync: ${automatic === "Enable automatic sync" ? "On" : "Off"}`
|
|
1321
|
+
].join("\n"),
|
|
1322
|
+
["Save setup", "Cancel"],
|
|
1323
|
+
signal
|
|
1324
|
+
);
|
|
1325
|
+
if (review !== "Save setup") return false;
|
|
1326
|
+
throwIfAborted4(signal);
|
|
1327
|
+
await awaitActive(
|
|
1328
|
+
signal,
|
|
1329
|
+
saveNewV3Settings(
|
|
1330
|
+
{
|
|
1331
|
+
setupName: targetName,
|
|
1332
|
+
connectionName: profileName,
|
|
1333
|
+
connection: {
|
|
1334
|
+
type: "webdav",
|
|
1335
|
+
url: connection.url,
|
|
1336
|
+
credentials: { username: connection.username, password: connection.password ?? "" }
|
|
1337
|
+
},
|
|
1338
|
+
setup: {
|
|
1339
|
+
storage: { connection: profileName, path: destination.path },
|
|
1340
|
+
sync: {
|
|
1341
|
+
include: [...content, ...sessions ? ["sessions"] : []],
|
|
1342
|
+
automatic: automatic === "Enable automatic sync"
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
signal
|
|
1347
|
+
)
|
|
1348
|
+
);
|
|
1349
|
+
ctx.ui.notify(`Sync setup \u201C${safe(targetName)}\u201D is ready. Use Sync now when ready.`, "info");
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
async function showAddWebDavTarget(ctx, name, profile, signal) {
|
|
1353
|
+
const location = await chooseDestination(ctx, name, signal);
|
|
1354
|
+
if (!location) return false;
|
|
1355
|
+
const destination = validateDestination(ctx, location.path);
|
|
1356
|
+
if (!destination) return false;
|
|
1357
|
+
const content = await chooseContent(ctx, signal);
|
|
1358
|
+
if (!content) return false;
|
|
1359
|
+
const review = await select(
|
|
1360
|
+
ctx,
|
|
1361
|
+
`Review WebDAV sync setup
|
|
1362
|
+
|
|
1363
|
+
Sync setup: ${safe(name)}
|
|
1364
|
+
Storage connection: ${safe(profile)}
|
|
1365
|
+
Storage location: ${safe(destination.path)}
|
|
1366
|
+
Included content: ${content.length} built-in groups \xB7 Sessions: Off
|
|
1367
|
+
Adding this setup does not sync or modify remote data.`,
|
|
1368
|
+
["Add sync setup", "Cancel"],
|
|
1369
|
+
signal
|
|
1370
|
+
);
|
|
1371
|
+
if (review !== "Add sync setup") return false;
|
|
1372
|
+
throwIfAborted4(signal);
|
|
1373
|
+
await awaitActive(
|
|
1374
|
+
signal,
|
|
1375
|
+
addSyncSetup(
|
|
1376
|
+
name,
|
|
1377
|
+
{
|
|
1378
|
+
storage: { connection: profile, path: destination.path },
|
|
1379
|
+
sync: { include: content, automatic: true }
|
|
1380
|
+
},
|
|
1381
|
+
signal
|
|
1382
|
+
)
|
|
1383
|
+
);
|
|
1384
|
+
ctx.ui.notify(`Added sync setup \u201C${safe(name)}\u201D.`, "info");
|
|
1385
|
+
return true;
|
|
1386
|
+
}
|
|
1387
|
+
async function showEditWebDavTarget(ctx, partial, signal) {
|
|
1388
|
+
const remotePath = await requiredInput2(ctx, "WebDAV storage path", partial.storagePath, signal);
|
|
1389
|
+
if (!remotePath) return false;
|
|
1390
|
+
const destination = validateDestination(ctx, remotePath);
|
|
1391
|
+
if (!destination) return false;
|
|
1392
|
+
const review = await select(
|
|
1393
|
+
ctx,
|
|
1394
|
+
`Review sync setup \u201C${safe(partial.setupName)}\u201D
|
|
1395
|
+
|
|
1396
|
+
Storage path: ${safe(partial.storagePath)} \u2192 ${safe(destination.path)}
|
|
1397
|
+
Saving changes the future storage location only; it does not move or delete remote data.`,
|
|
1398
|
+
["Save sync setup", "Cancel"],
|
|
1399
|
+
signal
|
|
1400
|
+
);
|
|
1401
|
+
if (review !== "Save sync setup") return false;
|
|
1402
|
+
throwIfAborted4(signal);
|
|
1403
|
+
await awaitActive(
|
|
1404
|
+
signal,
|
|
1405
|
+
updateSyncSetup(
|
|
1406
|
+
partial.setupName,
|
|
1407
|
+
(setup) => ({
|
|
1408
|
+
...setup,
|
|
1409
|
+
storage: { ...setup.storage, path: destination.path }
|
|
1410
|
+
}),
|
|
1411
|
+
{ expectedStorage: partial, signal }
|
|
1412
|
+
)
|
|
1413
|
+
);
|
|
1414
|
+
ctx.ui.notify(`Saved sync setup \u201C${safe(partial.setupName)}\u201D.`, "info");
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
async function showAddWebDavStorageProfile(ctx, signal) {
|
|
1418
|
+
const name = await requiredInput2(ctx, "Name this storage connection", "webdav", signal);
|
|
1419
|
+
if (!name) return false;
|
|
1420
|
+
const url = await requiredInput2(
|
|
1421
|
+
ctx,
|
|
1422
|
+
"WebDAV collection URL",
|
|
1423
|
+
"https://cloud.example.com/dav",
|
|
1424
|
+
signal
|
|
1425
|
+
);
|
|
1426
|
+
if (!url) return false;
|
|
1427
|
+
const username = await requiredInput2(ctx, "WebDAV username", "user", signal);
|
|
1428
|
+
if (!username) return false;
|
|
1429
|
+
const password = await awaitActive(signal, promptSecret(ctx, "WebDAV password", { signal }));
|
|
1430
|
+
if (password === void 0) return false;
|
|
1431
|
+
const connection = validateConnection(ctx, url, username, password);
|
|
1432
|
+
if (!connection) return false;
|
|
1433
|
+
const review = await select(
|
|
1434
|
+
ctx,
|
|
1435
|
+
`Review storage connection
|
|
1436
|
+
|
|
1437
|
+
Name: ${safe(name)}
|
|
1438
|
+
Type: WebDAV
|
|
1439
|
+
URL: ${displayUrl(connection.url)}
|
|
1440
|
+
Username: stored privately (value hidden)
|
|
1441
|
+
Password: configured (value hidden)
|
|
1442
|
+
Adding a connection does not contact the server or start syncing.`,
|
|
1443
|
+
["Add storage connection", "Cancel"],
|
|
1444
|
+
signal
|
|
1445
|
+
);
|
|
1446
|
+
if (review !== "Add storage connection") return false;
|
|
1447
|
+
throwIfAborted4(signal);
|
|
1448
|
+
await awaitActive(
|
|
1449
|
+
signal,
|
|
1450
|
+
addStorageConnection(
|
|
1451
|
+
name,
|
|
1452
|
+
{
|
|
1453
|
+
type: "webdav",
|
|
1454
|
+
url: connection.url,
|
|
1455
|
+
credentials: { username: connection.username, password: connection.password ?? "" }
|
|
1456
|
+
},
|
|
1457
|
+
signal
|
|
1458
|
+
)
|
|
1459
|
+
);
|
|
1460
|
+
ctx.ui.notify(`Added storage connection \u201C${safe(name)}\u201D.`, "info");
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
async function showEditWebDavStorageProfile(ctx, name, profile, signal, affectedSetups) {
|
|
1464
|
+
const url = await requiredInput2(
|
|
1465
|
+
ctx,
|
|
1466
|
+
"WebDAV collection URL",
|
|
1467
|
+
String(profile.url ?? "https://cloud.example.com/dav"),
|
|
1468
|
+
signal
|
|
1469
|
+
);
|
|
1470
|
+
if (!url) return false;
|
|
1471
|
+
const username = await requiredInput2(
|
|
1472
|
+
ctx,
|
|
1473
|
+
"WebDAV username",
|
|
1474
|
+
String(profile.username ?? "user"),
|
|
1475
|
+
signal
|
|
1476
|
+
);
|
|
1477
|
+
if (!username) return false;
|
|
1478
|
+
let password;
|
|
1479
|
+
let replacePassword = false;
|
|
1480
|
+
if (typeof profile.password === "string" && profile.password.length > 0) {
|
|
1481
|
+
const passwordAction = await select(
|
|
1482
|
+
ctx,
|
|
1483
|
+
"WebDAV password",
|
|
1484
|
+
["Keep current password", "Replace password", "Cancel"],
|
|
1485
|
+
signal
|
|
1486
|
+
);
|
|
1487
|
+
if (!passwordAction || passwordAction === "Cancel") return false;
|
|
1488
|
+
replacePassword = passwordAction === "Replace password";
|
|
1489
|
+
} else {
|
|
1490
|
+
replacePassword = true;
|
|
1491
|
+
}
|
|
1492
|
+
if (replacePassword) {
|
|
1493
|
+
password = await awaitActive(signal, promptSecret(ctx, "New WebDAV password", { signal }));
|
|
1494
|
+
if (password === void 0) return false;
|
|
1495
|
+
}
|
|
1496
|
+
const connection = validateConnection(ctx, url, username, password);
|
|
1497
|
+
if (!connection) return false;
|
|
1498
|
+
const review = await select(
|
|
1499
|
+
ctx,
|
|
1500
|
+
`Review storage connection
|
|
1501
|
+
|
|
1502
|
+
Storage connection: ${safe(name)}
|
|
1503
|
+
URL: ${displayUrl(String(profile.url ?? "https://invalid.invalid"))} \u2192 ${displayUrl(connection.url)}
|
|
1504
|
+
Username: stored privately (value hidden)
|
|
1505
|
+
Password: ${replacePassword ? "will be replaced" : "unchanged"} (value hidden)
|
|
1506
|
+
Affected sync setups: ${affectedSetups && affectedSetups.length > 0 ? affectedSetups.map(safe).join(", ") : "None"}
|
|
1507
|
+
Saving changes future storage access for every affected setup; it does not move remote data.`,
|
|
1508
|
+
["Save storage connection", "Cancel"],
|
|
1509
|
+
signal
|
|
1510
|
+
);
|
|
1511
|
+
if (review !== "Save storage connection") return false;
|
|
1512
|
+
throwIfAborted4(signal);
|
|
1513
|
+
await awaitActive(
|
|
1514
|
+
signal,
|
|
1515
|
+
updateStorageConnection(
|
|
1516
|
+
name,
|
|
1517
|
+
(current) => {
|
|
1518
|
+
if (current.type !== "webdav") {
|
|
1519
|
+
throw new Error("Storage connection type changed; reopen it.");
|
|
1520
|
+
}
|
|
1521
|
+
return {
|
|
1522
|
+
...current,
|
|
1523
|
+
url: connection.url,
|
|
1524
|
+
credentials: {
|
|
1525
|
+
...current.credentials,
|
|
1526
|
+
username: connection.username,
|
|
1527
|
+
password: replacePassword ? connection.password ?? current.credentials.password : current.credentials.password
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
},
|
|
1531
|
+
affectedSetups,
|
|
1532
|
+
signal
|
|
1533
|
+
)
|
|
1534
|
+
);
|
|
1535
|
+
ctx.ui.notify(`Saved storage connection \u201C${safe(name)}\u201D.`, "info");
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
async function chooseDestination(ctx, targetName, signal) {
|
|
1539
|
+
const remotePath = await requiredInput2(
|
|
1540
|
+
ctx,
|
|
1541
|
+
"WebDAV storage path",
|
|
1542
|
+
`pi-sync/${targetName}`,
|
|
1543
|
+
signal
|
|
1544
|
+
);
|
|
1545
|
+
return remotePath ? validateDestination(ctx, remotePath) : void 0;
|
|
1546
|
+
}
|
|
1547
|
+
async function chooseContent(ctx, signal) {
|
|
1548
|
+
const choice = await select(
|
|
1549
|
+
ctx,
|
|
1550
|
+
"Choose an initial sync preset",
|
|
1551
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
1552
|
+
signal
|
|
1553
|
+
);
|
|
1554
|
+
if (!choice || choice === "Cancel") return void 0;
|
|
1555
|
+
return choice === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
|
|
1556
|
+
}
|
|
1557
|
+
async function chooseSessions(ctx, signal) {
|
|
1558
|
+
const choice = await select(
|
|
1559
|
+
ctx,
|
|
1560
|
+
"Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
1561
|
+
["Keep sessions off (recommended)", "Include session conversations", "Cancel"],
|
|
1562
|
+
signal
|
|
1563
|
+
);
|
|
1564
|
+
if (!choice || choice === "Cancel") return void 0;
|
|
1565
|
+
if (choice !== "Include session conversations") return false;
|
|
1566
|
+
return confirm(
|
|
1567
|
+
ctx,
|
|
1568
|
+
"Include session conversations?",
|
|
1569
|
+
"I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
1570
|
+
signal
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
async function requiredInput2(ctx, title, placeholder, signal) {
|
|
1574
|
+
const value = await awaitActive(signal, ctx.ui.input(title, placeholder, { signal }));
|
|
1575
|
+
if (value === void 0) return void 0;
|
|
1576
|
+
const trimmed = value.trim();
|
|
1577
|
+
if (!trimmed) {
|
|
1578
|
+
ctx.ui.notify(`${title} is required.`, "warning");
|
|
1579
|
+
return void 0;
|
|
1580
|
+
}
|
|
1581
|
+
return trimmed;
|
|
1582
|
+
}
|
|
1583
|
+
async function select(ctx, title, options, signal) {
|
|
1584
|
+
return awaitActive(signal, ctx.ui.select(title, options, { signal }));
|
|
1585
|
+
}
|
|
1586
|
+
async function confirm(ctx, title, message, signal) {
|
|
1587
|
+
return awaitActive(signal, ctx.ui.confirm(title, message, { signal }));
|
|
1588
|
+
}
|
|
1589
|
+
async function awaitActive(signal, operation) {
|
|
1590
|
+
const result = await operation;
|
|
1591
|
+
throwIfAborted4(signal);
|
|
1592
|
+
return result;
|
|
1593
|
+
}
|
|
1594
|
+
function throwIfAborted4(signal) {
|
|
1595
|
+
if (!signal?.aborted) return;
|
|
1596
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
1597
|
+
}
|
|
1598
|
+
function validateConnection(ctx, url, username, password) {
|
|
1599
|
+
try {
|
|
1600
|
+
const normalizedUrl = normalizeWebDavUrl(url);
|
|
1601
|
+
if (!normalizedUrl) throw new Error("WebDAV URL is required.");
|
|
1602
|
+
validateWebDavCredentials(username, password);
|
|
1603
|
+
return {
|
|
1604
|
+
url: normalizedUrl,
|
|
1605
|
+
username: username.trim(),
|
|
1606
|
+
...password === void 0 ? {} : { password }
|
|
1607
|
+
};
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1610
|
+
return void 0;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
function validateDestination(ctx, path, namespace) {
|
|
1614
|
+
try {
|
|
1615
|
+
const basePath = normalizeWebDavPath(path);
|
|
1616
|
+
const normalizedPath = namespace ? normalizeWebDavPath(`${basePath}/${namespace.trim()}`) : basePath;
|
|
1617
|
+
const resolvedNamespace = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
|
|
1618
|
+
validateWebDavNamespace(resolvedNamespace);
|
|
1619
|
+
return { path: normalizedPath, namespace: resolvedNamespace };
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1622
|
+
return void 0;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
function displayUrl(value) {
|
|
1626
|
+
try {
|
|
1627
|
+
return `${new URL(value).origin}/\u2026`;
|
|
1628
|
+
} catch {
|
|
1629
|
+
return "invalid URL (value hidden)";
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
function safe(value) {
|
|
1633
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "\uFFFD");
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/storage-connections-ui.ts
|
|
1637
|
+
async function showStorageConnections(ctx, signal) {
|
|
1638
|
+
let selectedName;
|
|
1639
|
+
const nameById = /* @__PURE__ */ new Map();
|
|
1640
|
+
const menu = defineMenu3({
|
|
1641
|
+
start: "list",
|
|
1642
|
+
screens: {
|
|
1643
|
+
list: ({ state }) => {
|
|
1644
|
+
nameById.clear();
|
|
1645
|
+
const names = Object.keys(state.profiles).sort((left, right) => left.localeCompare(right));
|
|
1646
|
+
return {
|
|
1647
|
+
kind: "actions",
|
|
1648
|
+
title: "Storage connections",
|
|
1649
|
+
lines: state.version3 ? [] : ["Create version 3 settings before managing storage connections."],
|
|
1650
|
+
items: state.version3 ? [
|
|
1651
|
+
{ id: "add", label: "Add storage connection", action: "add" },
|
|
1652
|
+
...names.map((name, index) => {
|
|
1653
|
+
const id = `connection:${index}`;
|
|
1654
|
+
nameById.set(id, name);
|
|
1655
|
+
return {
|
|
1656
|
+
id,
|
|
1657
|
+
label: safeTerminalText2(name),
|
|
1658
|
+
action: "select"
|
|
1659
|
+
};
|
|
1660
|
+
})
|
|
1661
|
+
] : [],
|
|
1662
|
+
hint: "back"
|
|
1663
|
+
};
|
|
1664
|
+
},
|
|
1665
|
+
detail: ({ state }) => ({
|
|
1666
|
+
kind: "actions",
|
|
1667
|
+
title: state.selected ? `Storage connection \u201C${safeTerminalText2(state.selected.name)}\u201D` : "Storage connection",
|
|
1668
|
+
lines: state.selected?.lines ?? ["This storage connection no longer exists."],
|
|
1669
|
+
items: state.selected ? [
|
|
1670
|
+
{ id: "edit", label: "Edit storage connection\u2026", action: "edit" },
|
|
1671
|
+
...state.selected.usedBy.length === 0 ? [
|
|
1672
|
+
{
|
|
1673
|
+
id: "remove",
|
|
1674
|
+
label: "Remove storage connection\u2026",
|
|
1675
|
+
action: "remove"
|
|
1676
|
+
}
|
|
1677
|
+
] : [],
|
|
1678
|
+
{ id: "back", label: "Back", action: "back" }
|
|
1679
|
+
] : [{ id: "back", label: "Back", action: "back" }],
|
|
1680
|
+
hint: "back"
|
|
1681
|
+
})
|
|
1682
|
+
},
|
|
1683
|
+
actions: {
|
|
1684
|
+
add: async () => {
|
|
1685
|
+
try {
|
|
1686
|
+
await showAddStorageConnection(ctx, signal);
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
if (!signal?.aborted) {
|
|
1689
|
+
ctx.ui.notify(
|
|
1690
|
+
`Storage connection was not added: ${safeTerminalText2(errorMessage(error))} Retry from Add storage connection.`,
|
|
1691
|
+
"error"
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
return { kind: "stay" };
|
|
1696
|
+
},
|
|
1697
|
+
select: async ({ itemId }) => {
|
|
1698
|
+
selectedName = nameById.get(itemId);
|
|
1699
|
+
return selectedName ? { kind: "to", screen: "detail" } : { kind: "rejected" };
|
|
1700
|
+
},
|
|
1701
|
+
edit: async ({ state }) => {
|
|
1702
|
+
if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
|
|
1703
|
+
try {
|
|
1704
|
+
await editStorageConnection(
|
|
1705
|
+
ctx,
|
|
1706
|
+
state.selected.name,
|
|
1707
|
+
state.selected.profile,
|
|
1708
|
+
state.selected.usedBy,
|
|
1709
|
+
signal
|
|
1710
|
+
);
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
notifyConnectionError(ctx, state.selected.name, error, signal);
|
|
1713
|
+
}
|
|
1714
|
+
return { kind: "stay" };
|
|
1715
|
+
},
|
|
1716
|
+
remove: async ({ state }) => {
|
|
1717
|
+
if (!state.selected || state.selected.name !== selectedName) return { kind: "rejected" };
|
|
1718
|
+
const name = state.selected.name;
|
|
1719
|
+
const confirmed = await ctx.ui.confirm(
|
|
1720
|
+
"Remove storage connection?",
|
|
1721
|
+
`Remove local storage connection \u201C${safeTerminalText2(name)}\u201D? Remote data and history are not deleted.`,
|
|
1722
|
+
{ signal }
|
|
1723
|
+
);
|
|
1724
|
+
if (!confirmed || signal?.aborted) return { kind: "rejected" };
|
|
1725
|
+
try {
|
|
1726
|
+
await removeStorageConnection(name, signal);
|
|
1727
|
+
ctx.ui.notify(`Removed storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
1728
|
+
selectedName = void 0;
|
|
1729
|
+
return { kind: "back" };
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
notifyConnectionError(ctx, name, error, signal);
|
|
1732
|
+
return { kind: "stay" };
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
back: async () => {
|
|
1736
|
+
selectedName = void 0;
|
|
1737
|
+
return { kind: "back" };
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
await runMenu3(ctx, menu, {
|
|
1742
|
+
getState: () => loadStorageMenuState(selectedName, signal),
|
|
1743
|
+
signal,
|
|
1744
|
+
isCurrent: () => !signal?.aborted
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
async function loadStorageMenuState(selectedName, signal) {
|
|
1748
|
+
const raw = await readLocalConfigObject();
|
|
1749
|
+
if (signal?.aborted) throw signal.reason;
|
|
1750
|
+
const profiles = ownRecord(raw?.storageConnections) ?? {};
|
|
1751
|
+
const profile = selectedName ? ownRecord(profiles[selectedName]) : void 0;
|
|
1752
|
+
if (!selectedName || !profile) {
|
|
1753
|
+
return { version3: raw?.version === 3, profiles, selected: void 0 };
|
|
1754
|
+
}
|
|
1755
|
+
const usedBy = referencingSetups(raw, selectedName);
|
|
1756
|
+
return {
|
|
1757
|
+
version3: raw?.version === 3,
|
|
1758
|
+
profiles,
|
|
1759
|
+
selected: {
|
|
1760
|
+
name: selectedName,
|
|
1761
|
+
profile,
|
|
1762
|
+
usedBy,
|
|
1763
|
+
lines: [
|
|
1764
|
+
`Type: ${connectionType(profile)}`,
|
|
1765
|
+
`Endpoint: ${connectionEndpoint(profile)}`,
|
|
1766
|
+
`Credentials: ${credentialSource(profile)}`,
|
|
1767
|
+
`Used by: ${usedBy.length > 0 ? usedBy.map(safeTerminalText2).join(", ") : "No sync setups"}`,
|
|
1768
|
+
...usedBy.length > 0 ? ["Remove unavailable: edit or remove the listed sync setups first."] : []
|
|
1769
|
+
]
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
function notifyConnectionError(ctx, name, error, signal) {
|
|
1774
|
+
if (signal?.aborted) return;
|
|
1775
|
+
ctx.ui.notify(
|
|
1776
|
+
`Storage connection \u201C${safeTerminalText2(name)}\u201D was not changed: ${safeTerminalText2(errorMessage(error))} Reopen it and retry.`,
|
|
1777
|
+
"error"
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
async function editStorageConnection(ctx, name, profile, usedBy, signal) {
|
|
1781
|
+
if (ctx.mode !== "tui") {
|
|
1782
|
+
ctx.ui.notify(
|
|
1783
|
+
"Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
|
|
1784
|
+
"warning"
|
|
1785
|
+
);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (profile.type === "webdav") {
|
|
1789
|
+
await showEditWebDavStorageProfile(
|
|
1790
|
+
ctx,
|
|
1791
|
+
name,
|
|
1792
|
+
{ ...profile, kind: "webdav", ...ownRecord(profile.credentials) ?? {} },
|
|
1793
|
+
signal,
|
|
1794
|
+
usedBy
|
|
1795
|
+
);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (profile.type === "git") {
|
|
1799
|
+
await showEditGitStorageProfile(ctx, name, { ...profile, kind: "git" }, signal, usedBy);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
const endpoint = await requiredInput(
|
|
1803
|
+
ctx,
|
|
1804
|
+
"Endpoint",
|
|
1805
|
+
String(profile.endpoint ?? "https://s3.example.com"),
|
|
1806
|
+
signal
|
|
1807
|
+
);
|
|
1808
|
+
if (!endpoint || signal?.aborted) return;
|
|
1809
|
+
const region = await requiredInput(ctx, "Region", String(profile.region ?? "auto"), signal);
|
|
1810
|
+
if (!region || signal?.aborted) return;
|
|
1811
|
+
const storedCredentials = ownRecord(profile.credentials) ?? {};
|
|
1812
|
+
const credentials = await chooseS3CredentialUpdate(
|
|
1813
|
+
ctx,
|
|
1814
|
+
{ ...profile, ...storedCredentials },
|
|
1815
|
+
signal
|
|
1816
|
+
);
|
|
1817
|
+
if (!credentials || signal?.aborted) return;
|
|
1818
|
+
const save = await ctx.ui.select(
|
|
1819
|
+
[
|
|
1820
|
+
"Review storage connection",
|
|
1821
|
+
"",
|
|
1822
|
+
`Storage connection: ${safeTerminalText2(name)}`,
|
|
1823
|
+
`Endpoint: ${safeTerminalText2(String(profile.endpoint ?? "missing"))} \u2192 ${safeTerminalText2(endpoint)}`,
|
|
1824
|
+
`Region: ${safeTerminalText2(String(profile.region ?? "auto"))} \u2192 ${safeTerminalText2(region)}`,
|
|
1825
|
+
`Credentials: ${safeTerminalText2(credentials.summary)}`,
|
|
1826
|
+
`Affected sync setups: ${usedBy.length > 0 ? usedBy.map(safeTerminalText2).join(", ") : "None"}`,
|
|
1827
|
+
"Saving changes future storage access for every affected setup; it does not move remote data."
|
|
1828
|
+
].join("\n"),
|
|
1829
|
+
["Save storage connection", "Cancel"],
|
|
1830
|
+
{ signal }
|
|
1831
|
+
);
|
|
1832
|
+
if (save !== "Save storage connection" || signal?.aborted) return;
|
|
1833
|
+
await updateStorageConnection(
|
|
1834
|
+
name,
|
|
1835
|
+
(current) => {
|
|
1836
|
+
if (current.type !== "s3") {
|
|
1837
|
+
throw new Error("Storage connection type changed; reopen it.");
|
|
1838
|
+
}
|
|
1839
|
+
return {
|
|
1840
|
+
...current,
|
|
1841
|
+
endpoint,
|
|
1842
|
+
region,
|
|
1843
|
+
credentials: applyS3CredentialUpdate(
|
|
1844
|
+
current.credentials,
|
|
1845
|
+
credentials
|
|
1846
|
+
)
|
|
1847
|
+
};
|
|
1848
|
+
},
|
|
1849
|
+
usedBy,
|
|
1850
|
+
signal
|
|
1851
|
+
);
|
|
1852
|
+
if (signal?.aborted) return;
|
|
1853
|
+
ctx.ui.notify(`Saved storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
1854
|
+
}
|
|
1855
|
+
async function showAddStorageConnection(ctx, signal) {
|
|
1856
|
+
if (ctx.mode !== "tui") {
|
|
1857
|
+
ctx.ui.notify(
|
|
1858
|
+
"Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
|
|
1859
|
+
"warning"
|
|
1860
|
+
);
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
const preset = await ctx.ui.select(
|
|
1864
|
+
"Storage type",
|
|
1865
|
+
["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
|
|
1866
|
+
{ signal }
|
|
1867
|
+
);
|
|
1868
|
+
if (signal?.aborted || !preset || preset === "Cancel") return false;
|
|
1869
|
+
if (preset === "WebDAV") return showAddWebDavStorageProfile(ctx, signal);
|
|
1870
|
+
if (preset === "Git") return showAddGitStorageProfile(ctx, signal);
|
|
1871
|
+
const name = await requiredInput(
|
|
1872
|
+
ctx,
|
|
1873
|
+
"Name this storage connection",
|
|
1874
|
+
preset === "Cloudflare R2" ? "r2" : "s3",
|
|
1875
|
+
signal
|
|
1876
|
+
);
|
|
1877
|
+
if (!name || signal?.aborted) return false;
|
|
1878
|
+
const endpoint = await requiredInput(
|
|
1879
|
+
ctx,
|
|
1880
|
+
"Endpoint",
|
|
1881
|
+
preset === "Cloudflare R2" ? "https://<account-id>.r2.cloudflarestorage.com" : "https://s3.example.com",
|
|
1882
|
+
signal
|
|
1883
|
+
);
|
|
1884
|
+
if (!endpoint || signal?.aborted) return false;
|
|
1885
|
+
const region = preset === "Cloudflare R2" ? "auto" : await requiredInput(ctx, "Region", "us-east-1", signal);
|
|
1886
|
+
if (!region || signal?.aborted) return false;
|
|
1887
|
+
const credentials = await chooseS3Credentials(ctx, signal);
|
|
1888
|
+
if (!credentials || signal?.aborted) return false;
|
|
1889
|
+
const save = await ctx.ui.select(
|
|
1890
|
+
[
|
|
1891
|
+
"Review storage connection",
|
|
1892
|
+
"",
|
|
1893
|
+
`Name: ${safeTerminalText2(name)}`,
|
|
1894
|
+
`Type: ${preset}`,
|
|
1895
|
+
`Endpoint: ${safeTerminalText2(endpoint)}`,
|
|
1896
|
+
`Region: ${safeTerminalText2(region)}`,
|
|
1897
|
+
`Credentials: ${safeTerminalText2(credentials.summary)}`,
|
|
1898
|
+
"Adding a connection does not contact remote storage or start syncing."
|
|
1899
|
+
].join("\n"),
|
|
1900
|
+
["Add storage connection", "Cancel"],
|
|
1901
|
+
{ signal }
|
|
1902
|
+
);
|
|
1903
|
+
if (save !== "Add storage connection" || signal?.aborted) return false;
|
|
1904
|
+
await addStorageConnection(
|
|
1905
|
+
name,
|
|
1906
|
+
{
|
|
1907
|
+
type: "s3",
|
|
1908
|
+
endpoint,
|
|
1909
|
+
region,
|
|
1910
|
+
credentials: {
|
|
1911
|
+
accessKeyId: credentials.profileFields.accessKeyId ?? "",
|
|
1912
|
+
secretAccessKey: credentials.profileFields.secretAccessKey ?? ""
|
|
1913
|
+
}
|
|
1914
|
+
},
|
|
1915
|
+
signal
|
|
1916
|
+
);
|
|
1917
|
+
if (signal?.aborted) return true;
|
|
1918
|
+
ctx.ui.notify(`Added storage connection \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
1919
|
+
return true;
|
|
1920
|
+
}
|
|
1921
|
+
function referencingSetups(raw, connection) {
|
|
1922
|
+
return Object.entries(ownRecord(raw?.syncSetups) ?? {}).filter(([, value]) => ownRecord(ownRecord(value)?.storage)?.connection === connection).map(([name]) => name).sort((left, right) => left.localeCompare(right));
|
|
1923
|
+
}
|
|
1924
|
+
function connectionType(profile) {
|
|
1925
|
+
if (profile.type === "git") return "Git";
|
|
1926
|
+
if (profile.type === "webdav") return "WebDAV";
|
|
1927
|
+
if (profile.type === "s3" && typeof profile.endpoint === "string" && isCloudflareR2Endpoint(profile.endpoint)) {
|
|
1928
|
+
return "Cloudflare R2";
|
|
1929
|
+
}
|
|
1930
|
+
return "S3-compatible";
|
|
1931
|
+
}
|
|
1932
|
+
function connectionEndpoint(profile) {
|
|
1933
|
+
const value = profile.type === "git" ? profile.remote : profile.type === "webdav" ? profile.url : profile.endpoint;
|
|
1934
|
+
if (typeof value !== "string" || value.length === 0) return "Missing";
|
|
1935
|
+
if (profile.type === "git") return safeTerminalText2(value);
|
|
1936
|
+
try {
|
|
1937
|
+
return safeTerminalText2(new URL(value).host);
|
|
1938
|
+
} catch {
|
|
1939
|
+
return "Invalid";
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
function credentialSource(profile) {
|
|
1943
|
+
if (profile.type === "git") return "Git credential helper or SSH configuration";
|
|
1944
|
+
const credentials = ownRecord(profile.credentials);
|
|
1945
|
+
if (profile.type === "webdav") return credentials?.password ? "Settings file" : "Missing";
|
|
1946
|
+
if (credentials?.accessKeyId && credentials.secretAccessKey) return "Settings file";
|
|
1947
|
+
return "Missing";
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// src/manager-ui.ts
|
|
1951
|
+
async function showSyncManager(ctx, runRoute, sessionSignal, options = {}) {
|
|
1952
|
+
if (!ctx.hasUI) {
|
|
1953
|
+
await runRoute("help");
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
const menu = defineMenu4({
|
|
1957
|
+
start: "main",
|
|
1958
|
+
screens: {
|
|
1959
|
+
main: ({ state }) => {
|
|
1960
|
+
const attentionItems = attentionMainMenuItems(state.manager);
|
|
1961
|
+
const managerItems = state.manager.actions.map(
|
|
1962
|
+
(label) => blockedSyncMenuItem(label, state.manager) ?? syncMainMenuItem(label)
|
|
1963
|
+
);
|
|
1964
|
+
const operationFirst = state.manager.operation !== void 0 && state.manager.operation.kind !== "free";
|
|
1965
|
+
return {
|
|
1966
|
+
kind: "actions",
|
|
1967
|
+
title: "Manage sync",
|
|
1968
|
+
lines: state.manager.title.split("\n").slice(1),
|
|
1969
|
+
items: operationFirst ? [...managerItems, ...attentionItems] : [...attentionItems, ...managerItems],
|
|
1970
|
+
hint: "close"
|
|
1971
|
+
};
|
|
1972
|
+
},
|
|
1973
|
+
more: () => ({
|
|
1974
|
+
kind: "actions",
|
|
1975
|
+
title: "More options",
|
|
1976
|
+
items: [
|
|
1977
|
+
{ id: "pull", label: "Pull from remote\u2026", action: "pull" },
|
|
1978
|
+
{ id: "push", label: "Push to remote\u2026", action: "push" },
|
|
1979
|
+
{ id: "setups", label: "Sync setups\u2026", action: "setups" },
|
|
1980
|
+
{
|
|
1981
|
+
id: "connections",
|
|
1982
|
+
label: "Storage connections\u2026",
|
|
1983
|
+
action: "connections"
|
|
1984
|
+
},
|
|
1985
|
+
{ id: "recovery", label: "History & recovery\u2026", to: "recovery" },
|
|
1986
|
+
{ id: "help", label: "Help", action: "help" },
|
|
1987
|
+
{ id: "back", label: "Back", action: "back" }
|
|
1988
|
+
],
|
|
1989
|
+
hint: "back"
|
|
1990
|
+
}),
|
|
1991
|
+
recovery: ({ state }) => ({
|
|
1992
|
+
kind: "actions",
|
|
1993
|
+
title: "History & recovery",
|
|
1994
|
+
items: [
|
|
1995
|
+
{ id: "history", label: "Browse history", action: "history" },
|
|
1996
|
+
{ id: "doctor", label: "Check setup", action: "doctor" },
|
|
1997
|
+
...state.manager.operation && operationCanRecover(state.manager.operation) ? [{ id: "unlock", label: "Recover stale operation", action: "unlock" }] : [],
|
|
1998
|
+
{ id: "back", label: "Back", action: "back" }
|
|
1999
|
+
],
|
|
2000
|
+
hint: "back"
|
|
2001
|
+
})
|
|
2002
|
+
},
|
|
2003
|
+
actions: {
|
|
2004
|
+
"review-attention": async () => {
|
|
2005
|
+
const attention = options.getAttention?.();
|
|
2006
|
+
if (!attention) return { kind: "stay" };
|
|
2007
|
+
const disposition = await showManagerAttention(
|
|
2008
|
+
ctx,
|
|
2009
|
+
attention,
|
|
2010
|
+
runRoute,
|
|
2011
|
+
sessionSignal,
|
|
2012
|
+
() => options.onSelectionResolved?.(attention)
|
|
2013
|
+
);
|
|
2014
|
+
return { kind: disposition };
|
|
2015
|
+
},
|
|
2016
|
+
sync: async () => {
|
|
2017
|
+
const pendingAttention = options.getAttention?.();
|
|
2018
|
+
if (pendingAttention) {
|
|
2019
|
+
const activeConfig = await loadConfig();
|
|
2020
|
+
if (sessionSignal?.aborted) return { kind: "close" };
|
|
2021
|
+
if (pendingAttention.decision.setupName === activeConfig.setupName) {
|
|
2022
|
+
ctx.ui.notify("Review synced content before starting Sync now.", "warning");
|
|
2023
|
+
return { kind: "stay" };
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const result = await runCancellableOperation(
|
|
2027
|
+
ctx,
|
|
2028
|
+
"Checking current sync setup\u2026",
|
|
2029
|
+
"sync",
|
|
2030
|
+
runRoute,
|
|
2031
|
+
{
|
|
2032
|
+
commitAware: true,
|
|
2033
|
+
signal: sessionSignal
|
|
2034
|
+
}
|
|
2035
|
+
);
|
|
2036
|
+
const disposition = await dispatchManagerResult(
|
|
2037
|
+
ctx,
|
|
2038
|
+
result,
|
|
2039
|
+
"sync",
|
|
2040
|
+
runRoute,
|
|
2041
|
+
sessionSignal
|
|
2042
|
+
);
|
|
2043
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
2044
|
+
},
|
|
2045
|
+
switch: async () => {
|
|
2046
|
+
const result = await showSetupSwitcher(ctx, runRoute, void 0, sessionSignal);
|
|
2047
|
+
return result === "pull-attempted" || result === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
2048
|
+
},
|
|
2049
|
+
diff: async () => {
|
|
2050
|
+
const result = await runCancellableOperation(
|
|
2051
|
+
ctx,
|
|
2052
|
+
"Checking current sync setup\u2026",
|
|
2053
|
+
"diff",
|
|
2054
|
+
runRoute,
|
|
2055
|
+
{ signal: sessionSignal }
|
|
2056
|
+
);
|
|
2057
|
+
return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
2058
|
+
},
|
|
2059
|
+
settings: async () => {
|
|
2060
|
+
await showSyncSettings(ctx, runRoute, sessionSignal);
|
|
2061
|
+
return { kind: "stay" };
|
|
2062
|
+
},
|
|
2063
|
+
pull: async () => {
|
|
2064
|
+
const result = await runCancellableOperation(
|
|
2065
|
+
ctx,
|
|
2066
|
+
"Checking remote changes\u2026",
|
|
2067
|
+
"pull",
|
|
2068
|
+
runRoute,
|
|
2069
|
+
{
|
|
2070
|
+
commitAware: true,
|
|
2071
|
+
cancelledMessage: "Pull check cancelled; no local files were changed.",
|
|
2072
|
+
signal: sessionSignal
|
|
2073
|
+
}
|
|
2074
|
+
);
|
|
2075
|
+
const disposition = await dispatchManagerResult(
|
|
2076
|
+
ctx,
|
|
2077
|
+
result,
|
|
2078
|
+
"pull",
|
|
2079
|
+
runRoute,
|
|
2080
|
+
sessionSignal
|
|
2081
|
+
);
|
|
2082
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
2083
|
+
},
|
|
2084
|
+
push: async () => {
|
|
2085
|
+
const result = await runCancellableOperation(
|
|
2086
|
+
ctx,
|
|
2087
|
+
"Preparing push preview\u2026",
|
|
2088
|
+
"push",
|
|
2089
|
+
runRoute,
|
|
2090
|
+
{
|
|
2091
|
+
commitAware: true,
|
|
2092
|
+
cancelledMessage: "Push preparation cancelled; no remote files were changed.",
|
|
2093
|
+
signal: sessionSignal
|
|
2094
|
+
}
|
|
2095
|
+
);
|
|
2096
|
+
const disposition = await dispatchManagerResult(
|
|
2097
|
+
ctx,
|
|
2098
|
+
result,
|
|
2099
|
+
"push",
|
|
2100
|
+
runRoute,
|
|
2101
|
+
sessionSignal
|
|
2102
|
+
);
|
|
2103
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
2104
|
+
},
|
|
2105
|
+
setups: async () => {
|
|
2106
|
+
const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
|
|
2107
|
+
return result === "exit" ? { kind: "close" } : { kind: "stay" };
|
|
2108
|
+
},
|
|
2109
|
+
connections: async () => {
|
|
2110
|
+
await showStorageConnections(ctx, sessionSignal);
|
|
2111
|
+
return { kind: "stay" };
|
|
2112
|
+
},
|
|
2113
|
+
history: async () => {
|
|
2114
|
+
await runRoute("history");
|
|
2115
|
+
return { kind: "stay" };
|
|
2116
|
+
},
|
|
2117
|
+
doctor: async () => {
|
|
2118
|
+
await runRoute("doctor");
|
|
2119
|
+
return { kind: "stay" };
|
|
2120
|
+
},
|
|
2121
|
+
unlock: async ({ state, signal: actionSignal }) => {
|
|
2122
|
+
const result = await recoverSyncAccess(
|
|
2123
|
+
ctx,
|
|
2124
|
+
state.manager,
|
|
2125
|
+
runRoute,
|
|
2126
|
+
sessionSignal,
|
|
2127
|
+
actionSignal
|
|
2128
|
+
);
|
|
2129
|
+
if (result === "close") return { kind: "close" };
|
|
2130
|
+
return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" };
|
|
2131
|
+
},
|
|
2132
|
+
recover: async ({ state, signal: actionSignal }) => {
|
|
2133
|
+
const result = await recoverSyncAccess(
|
|
2134
|
+
ctx,
|
|
2135
|
+
state.manager,
|
|
2136
|
+
runRoute,
|
|
2137
|
+
sessionSignal,
|
|
2138
|
+
actionSignal
|
|
2139
|
+
);
|
|
2140
|
+
return { kind: result === "close" ? "close" : "stay" };
|
|
2141
|
+
},
|
|
2142
|
+
refresh: async () => ({ kind: "stay" }),
|
|
2143
|
+
help: async () => {
|
|
2144
|
+
await runRoute("help");
|
|
2145
|
+
return { kind: "close" };
|
|
2146
|
+
},
|
|
2147
|
+
init: async () => {
|
|
2148
|
+
await runRoute("init");
|
|
2149
|
+
return { kind: "stay" };
|
|
2150
|
+
},
|
|
2151
|
+
back: async () => ({ kind: "back" })
|
|
2152
|
+
}
|
|
2153
|
+
});
|
|
2154
|
+
await runMenu4(ctx, menu, {
|
|
2155
|
+
getState: async () => {
|
|
2156
|
+
const pendingAttention = options.getAttention?.();
|
|
2157
|
+
const manager = await describeManagerState(sessionSignal, pendingAttention);
|
|
2158
|
+
if (pendingAttention && options.getAttention?.() === pendingAttention && !manager.attention) {
|
|
2159
|
+
options.onSelectionResolved?.(pendingAttention);
|
|
2160
|
+
}
|
|
2161
|
+
return { manager };
|
|
2162
|
+
},
|
|
2163
|
+
signal: sessionSignal,
|
|
2164
|
+
isCurrent: () => !sessionSignal?.aborted
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
function syncMainMenuItem(label) {
|
|
2168
|
+
if (label === "More\u2026") return { id: "more", label, to: "more" };
|
|
2169
|
+
if (label === "History & recovery\u2026") return { id: "recovery", label, to: "recovery" };
|
|
2170
|
+
const actions = /* @__PURE__ */ new Map([
|
|
2171
|
+
["Sync now (recommended)", "sync"],
|
|
2172
|
+
["Switch sync setup", "switch"],
|
|
2173
|
+
["Status & changes", "diff"],
|
|
2174
|
+
["Settings", "settings"],
|
|
2175
|
+
["Restore sync access\u2026 (recommended)", "recover"],
|
|
2176
|
+
["Refresh operation status", "refresh"],
|
|
2177
|
+
["Sync setups\u2026", "setups"],
|
|
2178
|
+
["Storage connections\u2026", "connections"],
|
|
2179
|
+
["Help", "help"],
|
|
2180
|
+
["Set up sync", "init"],
|
|
2181
|
+
["Use existing settings", "init"]
|
|
2182
|
+
]);
|
|
2183
|
+
return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
|
|
2184
|
+
}
|
|
2185
|
+
async function showSetupWizard(ctx, signal) {
|
|
2186
|
+
if (ctx.mode !== "tui") {
|
|
2187
|
+
ctx.ui.notify(
|
|
2188
|
+
`Guided sync setup requires TUI mode for masked credential input. Create version 3 settings in ${safeTerminalText2(localConfigPath())}.`,
|
|
2189
|
+
"warning"
|
|
2190
|
+
);
|
|
2191
|
+
return false;
|
|
2192
|
+
}
|
|
2193
|
+
const preset = await ctx.ui.select(
|
|
2194
|
+
"Set up sync\n\nWhere will Pi settings be stored?",
|
|
2195
|
+
["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
|
|
2196
|
+
{ signal }
|
|
2197
|
+
);
|
|
2198
|
+
if (signal?.aborted || !preset || preset === "Cancel") return false;
|
|
2199
|
+
const targetName = await chooseInitialTargetName(ctx, signal);
|
|
2200
|
+
if (!targetName) return false;
|
|
2201
|
+
if (preset === "WebDAV") {
|
|
2202
|
+
const saved = await showWebDavSetup(ctx, targetName, signal);
|
|
2203
|
+
if (signal?.aborted) return false;
|
|
2204
|
+
if (saved) await refreshTargetCompletions();
|
|
2205
|
+
return saved;
|
|
2206
|
+
}
|
|
2207
|
+
if (preset === "Git") {
|
|
2208
|
+
const saved = await showGitSetup(ctx, targetName, signal);
|
|
2209
|
+
if (signal?.aborted) return false;
|
|
2210
|
+
if (saved) await refreshTargetCompletions();
|
|
2211
|
+
return saved;
|
|
2212
|
+
}
|
|
2213
|
+
const endpoint = await requiredInput(
|
|
2214
|
+
ctx,
|
|
2215
|
+
preset === "Cloudflare R2" ? "Cloudflare R2 endpoint" : "S3-compatible endpoint",
|
|
2216
|
+
preset === "Cloudflare R2" ? "https://<account-id>.r2.cloudflarestorage.com" : "https://s3.example.com",
|
|
2217
|
+
signal
|
|
2218
|
+
);
|
|
2219
|
+
if (!endpoint) return false;
|
|
2220
|
+
let region = "auto";
|
|
2221
|
+
if (preset !== "Cloudflare R2") {
|
|
2222
|
+
const selectedRegion = await requiredInput(ctx, "Storage region", "us-east-1", signal);
|
|
2223
|
+
if (!selectedRegion) return false;
|
|
2224
|
+
region = selectedRegion;
|
|
2225
|
+
}
|
|
2226
|
+
const location = await chooseInitialRemoteLocation(ctx, preset, targetName, signal);
|
|
2227
|
+
if (!location) return false;
|
|
2228
|
+
const { connectionName, bucket, path: storagePath } = location;
|
|
2229
|
+
const credentials = await chooseS3Credentials(ctx, signal);
|
|
2230
|
+
if (!credentials) return false;
|
|
2231
|
+
const contentChoice = await ctx.ui.select(
|
|
2232
|
+
"Choose an initial sync preset",
|
|
2233
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
2234
|
+
{ signal }
|
|
2235
|
+
);
|
|
2236
|
+
if (signal?.aborted || !contentChoice || contentChoice === "Cancel") return false;
|
|
2237
|
+
const syncFiles = contentChoice === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
|
|
2238
|
+
const automaticChoice = await ctx.ui.select(
|
|
2239
|
+
"Automatic sync for this setup",
|
|
2240
|
+
["Enable automatic sync", "Keep automatic sync off", "Cancel"],
|
|
2241
|
+
{ signal }
|
|
2242
|
+
);
|
|
2243
|
+
if (signal?.aborted || !automaticChoice || automaticChoice === "Cancel") return false;
|
|
2244
|
+
const sessionChoice = await ctx.ui.select(
|
|
2245
|
+
"Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
2246
|
+
["Keep sessions off (recommended)", "Include session conversations", "Cancel"],
|
|
2247
|
+
{ signal }
|
|
2248
|
+
);
|
|
2249
|
+
if (signal?.aborted || !sessionChoice || sessionChoice === "Cancel") return false;
|
|
2250
|
+
const syncSessions = sessionChoice === "Include session conversations";
|
|
2251
|
+
if (syncSessions && !await ctx.ui.confirm(
|
|
2252
|
+
"Include session conversations?",
|
|
2253
|
+
"I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
2254
|
+
{ signal }
|
|
2255
|
+
)) {
|
|
2256
|
+
return false;
|
|
2257
|
+
}
|
|
2258
|
+
const autoSync = automaticChoice === "Enable automatic sync";
|
|
2259
|
+
const choice = await ctx.ui.select(
|
|
2260
|
+
[
|
|
2261
|
+
"Review sync setup",
|
|
2262
|
+
"",
|
|
2263
|
+
`Sync setup: ${safeTerminalText2(targetName)}`,
|
|
2264
|
+
`Storage connection: ${safeTerminalText2(connectionName)} (${preset})`,
|
|
2265
|
+
`Endpoint: ${safeTerminalText2(endpoint)}`,
|
|
2266
|
+
`Bucket: ${safeTerminalText2(bucket)}`,
|
|
2267
|
+
`Storage location: ${safeTerminalText2(storagePath)}`,
|
|
2268
|
+
"Bucket must already exist. pi-sync will not create it.",
|
|
2269
|
+
`Included content: ${syncFiles.length} built-in groups \xB7 Sessions: ${syncSessions ? "On \u2014 privacy warning acknowledged" : "Off"}`,
|
|
2270
|
+
`Automatic sync: ${autoSync ? "On" : "Off"}`,
|
|
2271
|
+
`Credentials: ${safeTerminalText2(credentials.summary)}`
|
|
2272
|
+
].join("\n"),
|
|
2273
|
+
["Save sync setup", "Cancel"],
|
|
2274
|
+
{ signal }
|
|
2275
|
+
);
|
|
2276
|
+
if (signal?.aborted || choice !== "Save sync setup") return false;
|
|
2277
|
+
await saveNewV3Settings(
|
|
2278
|
+
{
|
|
2279
|
+
setupName: targetName,
|
|
2280
|
+
connectionName,
|
|
2281
|
+
connection: {
|
|
2282
|
+
type: "s3",
|
|
2283
|
+
endpoint,
|
|
2284
|
+
region,
|
|
2285
|
+
credentials: {
|
|
2286
|
+
accessKeyId: credentials.profileFields.accessKeyId ?? "",
|
|
2287
|
+
secretAccessKey: credentials.profileFields.secretAccessKey ?? ""
|
|
2288
|
+
}
|
|
2289
|
+
},
|
|
2290
|
+
setup: {
|
|
2291
|
+
storage: { connection: connectionName, bucket, path: storagePath },
|
|
2292
|
+
sync: {
|
|
2293
|
+
include: [...syncFiles, ...syncSessions ? ["sessions"] : []],
|
|
2294
|
+
automatic: autoSync
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
},
|
|
2298
|
+
signal
|
|
2299
|
+
);
|
|
2300
|
+
if (signal?.aborted) return false;
|
|
2301
|
+
await refreshTargetCompletions();
|
|
2302
|
+
if (signal?.aborted) return true;
|
|
2303
|
+
ctx.ui.notify(
|
|
2304
|
+
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.`,
|
|
2305
|
+
"info"
|
|
2306
|
+
);
|
|
2307
|
+
return true;
|
|
2308
|
+
}
|
|
2309
|
+
async function selectSetupForSwitch(ctx, raw, targets, active, signal) {
|
|
2310
|
+
let selectedName;
|
|
2311
|
+
const nameById = /* @__PURE__ */ new Map();
|
|
2312
|
+
const profiles = ownRecord(raw.storageConnections);
|
|
2313
|
+
const menu = defineMenu4({
|
|
2314
|
+
start: "setups",
|
|
2315
|
+
screens: {
|
|
2316
|
+
setups: () => ({
|
|
2317
|
+
kind: "actions",
|
|
2318
|
+
title: "Switch sync setup",
|
|
2319
|
+
lines: [`Current sync setup: ${safeTerminalText2(active ?? "none")}`],
|
|
2320
|
+
items: Object.keys(targets).sort((left, right) => left.localeCompare(right)).map((candidate, index) => {
|
|
2321
|
+
const target = ownRecord(targets[candidate]);
|
|
2322
|
+
const storage = ownRecord(target?.storage);
|
|
2323
|
+
const profileName = typeof storage?.connection === "string" ? storage.connection : void 0;
|
|
2324
|
+
const profile = profileName && profiles ? ownRecord(profiles[profileName]) : void 0;
|
|
2325
|
+
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"}`;
|
|
2326
|
+
const id = `setup:${index}`;
|
|
2327
|
+
nameById.set(id, candidate);
|
|
2328
|
+
return {
|
|
2329
|
+
id,
|
|
2330
|
+
label: `${safeTerminalText2(candidate)}${candidate === active ? " (current)" : ""}`,
|
|
2331
|
+
description: `${safeTerminalText2(profileName ?? "unknown")} \xB7 ${safeTerminalText2(location)}`,
|
|
2332
|
+
action: "select"
|
|
2333
|
+
};
|
|
2334
|
+
}),
|
|
2335
|
+
hint: "close"
|
|
2336
|
+
})
|
|
2337
|
+
},
|
|
2338
|
+
actions: {
|
|
2339
|
+
select: async ({ itemId }) => {
|
|
2340
|
+
selectedName = nameById.get(itemId);
|
|
2341
|
+
return { kind: "close" };
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
await runMenu4(ctx, menu, {
|
|
2346
|
+
getState: () => void 0,
|
|
2347
|
+
signal,
|
|
2348
|
+
isCurrent: () => !signal?.aborted
|
|
2349
|
+
});
|
|
2350
|
+
return selectedName;
|
|
2351
|
+
}
|
|
2352
|
+
async function showSetupSwitcher(ctx, runRoute, selectedName, signal) {
|
|
2353
|
+
const raw = await readLocalConfigObject();
|
|
2354
|
+
if (signal?.aborted) return false;
|
|
2355
|
+
if (raw?.version !== 3) {
|
|
2356
|
+
ctx.ui.notify("Add a second sync setup before switching setups.", "info");
|
|
2357
|
+
return false;
|
|
2358
|
+
}
|
|
2359
|
+
const targets = ownRecord(raw.syncSetups);
|
|
2360
|
+
if (!targets) {
|
|
2361
|
+
ctx.ui.notify("No sync setups are configured.", "warning");
|
|
2362
|
+
return false;
|
|
2363
|
+
}
|
|
2364
|
+
const active = typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : void 0;
|
|
2365
|
+
let name = selectedName;
|
|
2366
|
+
if (!name) {
|
|
2367
|
+
name = await selectSetupForSwitch(ctx, raw, targets, active, signal);
|
|
2368
|
+
if (!name) return false;
|
|
2369
|
+
}
|
|
2370
|
+
if (!name || !Object.hasOwn(targets, name)) {
|
|
2371
|
+
ctx.ui.notify(
|
|
2372
|
+
`Sync setup \u201C${safeTerminalText2(name ?? "unknown")}\u201D no longer exists.`,
|
|
2373
|
+
"warning"
|
|
2374
|
+
);
|
|
2375
|
+
return false;
|
|
2376
|
+
}
|
|
2377
|
+
if (name === active) {
|
|
2378
|
+
ctx.ui.notify(`Sync setup \u201C${safeTerminalText2(name)}\u201D is already current.`, "info");
|
|
2379
|
+
return false;
|
|
2380
|
+
}
|
|
2381
|
+
let config;
|
|
2382
|
+
try {
|
|
2383
|
+
config = await loadConfig(name);
|
|
2384
|
+
if (signal?.aborted) return false;
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
ctx.ui.notify(
|
|
2387
|
+
`Cannot use sync setup \u201C${safeTerminalText2(name)}\u201D: ${safeTerminalText2(errorMessage(error))}`,
|
|
2388
|
+
"error"
|
|
2389
|
+
);
|
|
2390
|
+
return false;
|
|
2391
|
+
}
|
|
2392
|
+
const onSwitch = await loadOnSwitch();
|
|
2393
|
+
if (signal?.aborted) return false;
|
|
2394
|
+
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.";
|
|
2395
|
+
const confirmed = await ctx.ui.confirm(
|
|
2396
|
+
"Switch sync setup?",
|
|
2397
|
+
[
|
|
2398
|
+
`From: ${safeTerminalText2(active ?? "none")}`,
|
|
2399
|
+
`To: ${safeTerminalText2(name)}`,
|
|
2400
|
+
`Storage: ${backendStorageDescription(config)}`,
|
|
2401
|
+
`Included content: ${config.include.length} paths`,
|
|
2402
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"} \xB7 Sessions: ${config.include.includes("sessions") ? "On" : "Off"}`,
|
|
2403
|
+
"",
|
|
2404
|
+
switchEffect
|
|
2405
|
+
].join("\n"),
|
|
2406
|
+
{ signal }
|
|
2407
|
+
);
|
|
2408
|
+
if (signal?.aborted || !confirmed) return false;
|
|
2409
|
+
try {
|
|
2410
|
+
let pullClosed = false;
|
|
2411
|
+
const result = await useSyncSetup(
|
|
2412
|
+
ctx,
|
|
2413
|
+
name,
|
|
2414
|
+
async (selectedTarget) => {
|
|
2415
|
+
const pullResult = await runCancellableOperation(
|
|
2416
|
+
ctx,
|
|
2417
|
+
`Pulling sync setup \u201C${safeTerminalText2(name)}\u201D\u2026`,
|
|
2418
|
+
"pull",
|
|
2419
|
+
runRoute,
|
|
2420
|
+
{
|
|
2421
|
+
commitAware: true,
|
|
2422
|
+
cancelledMessage: null,
|
|
2423
|
+
target: selectedTarget,
|
|
2424
|
+
signal
|
|
2425
|
+
}
|
|
2426
|
+
);
|
|
2427
|
+
const disposition = await dispatchManagerResult(ctx, pullResult, "pull", runRoute, signal);
|
|
2428
|
+
if (pullResult.kind === "closed" || pullResult.kind.endsWith("required")) {
|
|
2429
|
+
pullClosed = disposition.kind === "close";
|
|
2430
|
+
}
|
|
2431
|
+
if (disposition.appliedRoute === "pull") return "applied";
|
|
2432
|
+
if (pullResult.kind === "completed") return pullResult.outcome;
|
|
2433
|
+
return pullResult.kind === "cancelled" ? "cancelled" : void 0;
|
|
2434
|
+
},
|
|
2435
|
+
onSwitch,
|
|
2436
|
+
signal,
|
|
2437
|
+
syncConfigReviewIdentity(config)
|
|
2438
|
+
);
|
|
2439
|
+
if (pullClosed) return "closed";
|
|
2440
|
+
return result.pullApplied ? "pull-attempted" : "switched";
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
if (signal?.aborted) return false;
|
|
2443
|
+
ctx.ui.notify(
|
|
2444
|
+
`Sync setup \u201C${safeTerminalText2(name)}\u201D was not switched: ${safeTerminalText2(errorMessage(error))}`,
|
|
2445
|
+
"error"
|
|
2446
|
+
);
|
|
2447
|
+
return false;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
async function showSyncSetupManager(ctx, runRoute, signal) {
|
|
2451
|
+
return showSyncSetups(
|
|
2452
|
+
ctx,
|
|
2453
|
+
{
|
|
2454
|
+
add: async (setupSignal) => {
|
|
2455
|
+
await showAddTarget(ctx, setupSignal);
|
|
2456
|
+
},
|
|
2457
|
+
edit: async (name, setupSignal) => {
|
|
2458
|
+
await showEditTarget(ctx, name, setupSignal);
|
|
2459
|
+
},
|
|
2460
|
+
makeCurrent: async (name, setupSignal) => {
|
|
2461
|
+
const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
|
|
2462
|
+
return result === "pull-attempted" || result === "closed" ? "exit" : void 0;
|
|
2463
|
+
},
|
|
2464
|
+
remove: async (name, setupSignal) => {
|
|
2465
|
+
await showRemoveTarget(ctx, name, setupSignal);
|
|
2466
|
+
}
|
|
2467
|
+
},
|
|
2468
|
+
signal
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
async function showAddTarget(ctx, signal) {
|
|
2472
|
+
let raw = await readLocalConfigObject();
|
|
2473
|
+
if (signal?.aborted) return;
|
|
2474
|
+
if (!raw) return void ctx.ui.notify("Set up the first sync setup before adding another.", "info");
|
|
2475
|
+
if (raw.version !== 3) {
|
|
2476
|
+
ctx.ui.notify(
|
|
2477
|
+
"Version 1 and version 2 settings are unsupported and are never migrated.",
|
|
2478
|
+
"error"
|
|
2479
|
+
);
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
let profiles = ownRecord(raw.storageConnections) ?? {};
|
|
2483
|
+
const name = await requiredInput(ctx, "Name the new sync setup", "work", signal);
|
|
2484
|
+
if (!name) return;
|
|
2485
|
+
const createConnection = "Add a new storage connection\u2026";
|
|
2486
|
+
let profile = await ctx.ui.select(
|
|
2487
|
+
"Choose a storage connection",
|
|
2488
|
+
[...Object.keys(profiles).sort(), createConnection, "Cancel"],
|
|
2489
|
+
{ signal }
|
|
2490
|
+
);
|
|
2491
|
+
if (!profile || profile === "Cancel") return;
|
|
2492
|
+
if (profile === createConnection) {
|
|
2493
|
+
const previousNames = new Set(Object.keys(profiles));
|
|
2494
|
+
if (!await showAddStorageConnection(ctx, signal)) return;
|
|
2495
|
+
if (signal?.aborted) return;
|
|
2496
|
+
raw = await readLocalConfigObject() ?? raw;
|
|
2497
|
+
if (signal?.aborted) return;
|
|
2498
|
+
profiles = ownRecord(raw.storageConnections) ?? {};
|
|
2499
|
+
profile = Object.keys(profiles).find((candidate) => !previousNames.has(candidate));
|
|
2500
|
+
if (!profile) return;
|
|
2501
|
+
}
|
|
2502
|
+
const storageKind = ownRecord(profiles[profile])?.type;
|
|
2503
|
+
if (storageKind === "webdav") {
|
|
2504
|
+
const saved = await showAddWebDavTarget(ctx, name, profile, signal);
|
|
2505
|
+
if (signal?.aborted) return;
|
|
2506
|
+
if (saved) await refreshTargetCompletions();
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
if (storageKind === "git") {
|
|
2510
|
+
const saved = await showAddGitTarget(ctx, name, profile, signal);
|
|
2511
|
+
if (signal?.aborted) return;
|
|
2512
|
+
if (saved) await refreshTargetCompletions();
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
const location = await chooseAdditionalRemoteLocation(ctx, raw, profile, name, signal);
|
|
2516
|
+
if (!location) return;
|
|
2517
|
+
const { bucket, path: storagePath } = location;
|
|
2518
|
+
const preset = await ctx.ui.select(
|
|
2519
|
+
"Choose included content",
|
|
2520
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
2521
|
+
{ signal }
|
|
2522
|
+
);
|
|
2523
|
+
if (!preset || preset === "Cancel") return;
|
|
2524
|
+
const syncFiles = preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
|
|
2525
|
+
const overlapsExistingTarget = Object.values(ownRecord(raw.syncSetups) ?? {}).some((value) => {
|
|
2526
|
+
const existing = ownRecord(value);
|
|
2527
|
+
const sync = ownRecord(existing?.sync);
|
|
2528
|
+
const selected = syncIncludeSelection(
|
|
2529
|
+
Array.isArray(sync?.include) ? sync.include : []
|
|
2530
|
+
).builtIns;
|
|
2531
|
+
return selected.some((item) => syncFiles.includes(item));
|
|
2532
|
+
});
|
|
2533
|
+
const choice = await ctx.ui.select(
|
|
2534
|
+
[
|
|
2535
|
+
"Review new sync setup",
|
|
2536
|
+
"",
|
|
2537
|
+
`Sync setup: ${safeTerminalText2(name)}`,
|
|
2538
|
+
`Storage connection: ${safeTerminalText2(profile)}`,
|
|
2539
|
+
`Bucket: ${safeTerminalText2(bucket)}`,
|
|
2540
|
+
`Storage location: ${safeTerminalText2(storagePath)}`,
|
|
2541
|
+
"Bucket must already exist. pi-sync will not create it.",
|
|
2542
|
+
`Included content: ${syncFiles.length} built-in groups \xB7 Sessions: Off`,
|
|
2543
|
+
...overlapsExistingTarget ? [
|
|
2544
|
+
"Warning: this setup shares local content with another setup; only the current setup syncs automatically."
|
|
2545
|
+
] : [],
|
|
2546
|
+
"Adding this setup does not sync or modify remote data."
|
|
2547
|
+
].join("\n"),
|
|
2548
|
+
["Add sync setup", "Cancel"],
|
|
2549
|
+
{ signal }
|
|
2550
|
+
);
|
|
2551
|
+
if (signal?.aborted || choice !== "Add sync setup") return;
|
|
2552
|
+
await addSyncSetup(
|
|
2553
|
+
name,
|
|
2554
|
+
{
|
|
2555
|
+
storage: { connection: profile, bucket, path: storagePath },
|
|
2556
|
+
sync: { include: syncFiles, automatic: true }
|
|
2557
|
+
},
|
|
2558
|
+
signal
|
|
2559
|
+
);
|
|
2560
|
+
if (signal?.aborted) return;
|
|
2561
|
+
await refreshTargetCompletions();
|
|
2562
|
+
ctx.ui.notify(`Added sync setup \u201C${safeTerminalText2(name)}\u201D.`, "info");
|
|
2563
|
+
}
|
|
2564
|
+
async function showEditTarget(ctx, name, signal) {
|
|
2565
|
+
const partial = await loadPartialConfig(name);
|
|
2566
|
+
if (signal?.aborted) return;
|
|
2567
|
+
if (!partial.setupName) {
|
|
2568
|
+
ctx.ui.notify("Create version 3 settings before editing a named sync setup.", "info");
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
if (partial.storageKind === "webdav") {
|
|
2572
|
+
await showEditWebDavTarget(ctx, partial, signal);
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
if (partial.storageKind === "git") {
|
|
2576
|
+
await showEditGitTarget(ctx, partial, signal);
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
const bucket = await requiredInput(ctx, "Bucket", partial.bucket ?? "pi-sync", signal);
|
|
2580
|
+
if (!bucket) return;
|
|
2581
|
+
const storagePath = await requiredInput(ctx, "Storage path", partial.storagePath, signal);
|
|
2582
|
+
if (!storagePath) return;
|
|
2583
|
+
const normalizedPath = storagePath.replace(/^\/+|\/+$/gu, "");
|
|
2584
|
+
const choice = await ctx.ui.select(
|
|
2585
|
+
[
|
|
2586
|
+
`Review sync setup \u201C${safeTerminalText2(partial.setupName)}\u201D`,
|
|
2587
|
+
"",
|
|
2588
|
+
`Bucket: ${safeTerminalText2(partial.bucket ?? "missing")} \u2192 ${safeTerminalText2(bucket)}`,
|
|
2589
|
+
`Storage path: ${safeTerminalText2(partial.storagePath ?? "missing")} \u2192 ${safeTerminalText2(normalizedPath)}`,
|
|
2590
|
+
"Saving changes the future storage location only; it does not move or delete remote data."
|
|
2591
|
+
].join("\n"),
|
|
2592
|
+
["Save sync setup", "Cancel"],
|
|
2593
|
+
{ signal }
|
|
2594
|
+
);
|
|
2595
|
+
if (signal?.aborted || choice !== "Save sync setup") return;
|
|
2596
|
+
await updateSyncSetup(
|
|
2597
|
+
partial.setupName,
|
|
2598
|
+
(setup) => {
|
|
2599
|
+
if (typeof setup.storage.bucket !== "string") {
|
|
2600
|
+
throw new Error("Sync setup storage type changed; reopen it.");
|
|
2601
|
+
}
|
|
2602
|
+
return {
|
|
2603
|
+
...setup,
|
|
2604
|
+
storage: { ...setup.storage, bucket, path: normalizedPath }
|
|
2605
|
+
};
|
|
2606
|
+
},
|
|
2607
|
+
{ expectedStorage: partial, signal }
|
|
2608
|
+
);
|
|
2609
|
+
if (signal?.aborted) return;
|
|
2610
|
+
ctx.ui.notify(`Saved sync setup \u201C${safeTerminalText2(partial.setupName)}\u201D.`, "info");
|
|
2611
|
+
}
|
|
2612
|
+
async function showRemoveTarget(ctx, name, signal) {
|
|
2613
|
+
const confirmed = await ctx.ui.confirm(
|
|
2614
|
+
"Remove sync setup?",
|
|
2615
|
+
`Remove local sync setup \u201C${safeTerminalText2(name)}\u201D? Remote data and history are not deleted.`,
|
|
2616
|
+
{ signal }
|
|
2617
|
+
);
|
|
2618
|
+
if (signal?.aborted || !confirmed) return;
|
|
2619
|
+
await removeSyncSetup(name, signal);
|
|
2620
|
+
if (signal?.aborted) return;
|
|
2621
|
+
await refreshTargetCompletions();
|
|
2622
|
+
ctx.ui.notify(
|
|
2623
|
+
`Removed sync setup \u201C${safeTerminalText2(name)}\u201D; remote data was not deleted.`,
|
|
2624
|
+
"info"
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
async function refreshTargetCompletions() {
|
|
2628
|
+
setSyncSetupCompletions(await configuredSyncSetupNames());
|
|
2629
|
+
}
|
|
2630
|
+
async function chooseInitialTargetName(ctx, signal) {
|
|
2631
|
+
const purpose = await ctx.ui.select(
|
|
2632
|
+
"What will this sync setup be used for?",
|
|
2633
|
+
["Personal / Home", "Work", "Custom", "Cancel"],
|
|
2634
|
+
{ signal }
|
|
2635
|
+
);
|
|
2636
|
+
if (!purpose || purpose === "Cancel") return void 0;
|
|
2637
|
+
if (purpose === "Personal / Home") return "home";
|
|
2638
|
+
if (purpose === "Work") return "work";
|
|
2639
|
+
return requiredInput(ctx, "Name this sync setup", "default", signal);
|
|
2640
|
+
}
|
|
2641
|
+
async function chooseInitialRemoteLocation(ctx, preset, setupName, signal) {
|
|
2642
|
+
const connectionName = preset === "Cloudflare R2" ? "r2" : "s3";
|
|
2643
|
+
const suggested = {
|
|
2644
|
+
connectionName,
|
|
2645
|
+
bucket: "pi-sync",
|
|
2646
|
+
path: `pi-sync/${setupName}`
|
|
2647
|
+
};
|
|
2648
|
+
if (preset === "Cloudflare R2") {
|
|
2649
|
+
const choice2 = await ctx.ui.select(
|
|
2650
|
+
[
|
|
2651
|
+
"Choose storage location",
|
|
2652
|
+
"",
|
|
2653
|
+
`Suggested storage connection: ${connectionName}`,
|
|
2654
|
+
`Suggested bucket: ${suggested.bucket}`,
|
|
2655
|
+
`Remote path: ${safeTerminalText2(suggested.path)}`,
|
|
2656
|
+
"Bucket must already exist. pi-sync will not create it."
|
|
2657
|
+
].join("\n"),
|
|
2658
|
+
["Use suggested location (recommended)", "Customize remote location", "Cancel"],
|
|
2659
|
+
{ signal }
|
|
2660
|
+
);
|
|
2661
|
+
if (!choice2 || choice2 === "Cancel") return void 0;
|
|
2662
|
+
if (choice2 === "Use suggested location (recommended)") return suggested;
|
|
2663
|
+
return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
|
|
2664
|
+
}
|
|
2665
|
+
const choice = await ctx.ui.select(
|
|
2666
|
+
[
|
|
2667
|
+
"Choose storage location",
|
|
2668
|
+
"",
|
|
2669
|
+
`Suggested storage connection: ${connectionName}`,
|
|
2670
|
+
`Suggested path: ${safeTerminalText2(suggested.path)}`,
|
|
2671
|
+
"S3 bucket names may need to be globally unique and the bucket must already exist."
|
|
2672
|
+
].join("\n"),
|
|
2673
|
+
[
|
|
2674
|
+
"Use existing bucket with suggested path (recommended)",
|
|
2675
|
+
"Customize remote location",
|
|
2676
|
+
"Cancel"
|
|
2677
|
+
],
|
|
2678
|
+
{ signal }
|
|
2679
|
+
);
|
|
2680
|
+
if (!choice || choice === "Cancel") return void 0;
|
|
2681
|
+
if (choice === "Customize remote location") {
|
|
2682
|
+
return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
|
|
2683
|
+
}
|
|
2684
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal);
|
|
2685
|
+
return bucket ? { ...suggested, bucket } : void 0;
|
|
2686
|
+
}
|
|
2687
|
+
async function chooseAdditionalRemoteLocation(ctx, settings, connectionName, setupName, signal) {
|
|
2688
|
+
const setups = ownRecord(settings.syncSetups) ?? {};
|
|
2689
|
+
const currentSetup = typeof settings.activeSyncSetup === "string" ? settings.activeSyncSetup : void 0;
|
|
2690
|
+
const candidates = Object.entries(setups).map(([name, value]) => ({ name, storage: ownRecord(ownRecord(value)?.storage) })).filter(
|
|
2691
|
+
(item) => item.storage?.connection === connectionName && typeof item.storage.bucket === "string"
|
|
2692
|
+
);
|
|
2693
|
+
const source = candidates.find((item) => item.name === currentSetup) ?? candidates.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
2694
|
+
if (source) {
|
|
2695
|
+
const sourcePath = typeof source.storage.path === "string" ? source.storage.path : "pi-sync/home";
|
|
2696
|
+
const sourceParent = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "pi-sync";
|
|
2697
|
+
const suggestedPath2 = `${sourceParent}/${setupName}`;
|
|
2698
|
+
const sameBucketLabel = `Same bucket as \u201C${safeTerminalText2(source.name)}\u201D (recommended)`;
|
|
2699
|
+
const choice2 = await ctx.ui.select(
|
|
2700
|
+
[
|
|
2701
|
+
`Storage location for \u201C${safeTerminalText2(setupName)}\u201D`,
|
|
2702
|
+
"",
|
|
2703
|
+
`Recommended bucket: ${safeTerminalText2(String(source.storage.bucket))}`,
|
|
2704
|
+
`Remote path: ${safeTerminalText2(suggestedPath2)}`,
|
|
2705
|
+
"The complete path and local sync state remain separate."
|
|
2706
|
+
].join("\n"),
|
|
2707
|
+
[sameBucketLabel, "Use a different bucket", "Customize remote location", "Cancel"],
|
|
2708
|
+
{ signal }
|
|
2709
|
+
);
|
|
2710
|
+
if (!choice2 || choice2 === "Cancel") return void 0;
|
|
2711
|
+
if (choice2 === sameBucketLabel) {
|
|
2712
|
+
return { bucket: String(source.storage.bucket), path: suggestedPath2 };
|
|
2713
|
+
}
|
|
2714
|
+
if (choice2 === "Use a different bucket") {
|
|
2715
|
+
const bucket2 = await requiredExistingBucket(ctx, "pi-sync", signal);
|
|
2716
|
+
return bucket2 ? { bucket: bucket2, path: `pi-sync/${setupName}` } : void 0;
|
|
2717
|
+
}
|
|
2718
|
+
const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal);
|
|
2719
|
+
return custom ? { bucket: custom.bucket, path: custom.path } : void 0;
|
|
2720
|
+
}
|
|
2721
|
+
const connectionSettings = ownRecord(ownRecord(settings.storageConnections)?.[connectionName]);
|
|
2722
|
+
const isR2 = isCloudflareR2Endpoint(String(connectionSettings?.endpoint ?? ""));
|
|
2723
|
+
const suggestedPath = `pi-sync/${setupName}`;
|
|
2724
|
+
const suggestedLabel = isR2 ? "Use suggested location (recommended)" : "Use existing bucket with suggested path (recommended)";
|
|
2725
|
+
const choice = await ctx.ui.select(
|
|
2726
|
+
`Storage location for \u201C${safeTerminalText2(setupName)}\u201D
|
|
2727
|
+
|
|
2728
|
+
Suggested path: ${safeTerminalText2(suggestedPath)}`,
|
|
2729
|
+
[suggestedLabel, "Customize remote location", "Cancel"],
|
|
2730
|
+
{ signal }
|
|
2731
|
+
);
|
|
2732
|
+
if (!choice || choice === "Cancel") return void 0;
|
|
2733
|
+
if (choice === "Customize remote location") {
|
|
2734
|
+
const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal);
|
|
2735
|
+
return custom ? { bucket: custom.bucket, path: custom.path } : void 0;
|
|
2736
|
+
}
|
|
2737
|
+
if (isR2) return { bucket: "pi-sync", path: suggestedPath };
|
|
2738
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal);
|
|
2739
|
+
return bucket ? { bucket, path: suggestedPath } : void 0;
|
|
2740
|
+
}
|
|
2741
|
+
async function chooseCustomRemoteLocation(ctx, setupName, initialConnectionName, customizeConnectionName, signal) {
|
|
2742
|
+
const connectionName = customizeConnectionName ? await requiredInput(ctx, "Storage connection name", initialConnectionName, signal) : initialConnectionName;
|
|
2743
|
+
if (!connectionName) return void 0;
|
|
2744
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
|
|
2745
|
+
if (!bucket) return void 0;
|
|
2746
|
+
const storagePath = await requiredInput(ctx, "Storage path", `pi-sync/${setupName}`, signal);
|
|
2747
|
+
if (!storagePath) return void 0;
|
|
2748
|
+
return { connectionName, bucket, path: storagePath.replace(/^\/+|\/+$/gu, "") };
|
|
2749
|
+
}
|
|
2750
|
+
export {
|
|
2751
|
+
showSetupWizard,
|
|
2752
|
+
showSyncManager
|
|
2753
|
+
};
|
|
2754
|
+
//# sourceMappingURL=manager-ui-7E25VWUP.ts.map
|