@narumitw/pi-sync 0.30.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -236
- package/package.json +14 -6
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/paths.ts +4 -1
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/snapshot.ts
CHANGED
|
@@ -10,20 +10,25 @@ export { isDeniedPath } from "./paths.js";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
canonicalSnapshotPathForConfig,
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
customIncludePathsByLower,
|
|
14
|
+
DEFAULT_SYNC_INCLUDE,
|
|
15
|
+
includeFromSelectionConfig,
|
|
15
16
|
isConfiguredSnapshotPath,
|
|
16
17
|
isPreservableUnmanagedSnapshotPath,
|
|
17
18
|
normalizeExtraFiles,
|
|
18
19
|
normalizeSyncFiles,
|
|
20
|
+
type SyncSelectionConfig,
|
|
21
|
+
syncIncludeSelection,
|
|
19
22
|
} from "./sync-policy.js";
|
|
20
|
-
import type { Snapshot, SnapshotFile, SnapshotOptions
|
|
23
|
+
import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
|
|
21
24
|
|
|
22
25
|
export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "./sync-policy.js";
|
|
23
26
|
|
|
24
27
|
const VERSION = 1;
|
|
25
|
-
const TOP_LEVEL_FILES: readonly string[] =
|
|
26
|
-
|
|
28
|
+
const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_INCLUDE.filter((name) =>
|
|
29
|
+
name.includes("."),
|
|
30
|
+
);
|
|
31
|
+
const TOP_LEVEL_DIRS = new Set<string>(DEFAULT_SYNC_INCLUDE.filter((name) => !name.includes(".")));
|
|
27
32
|
const SECRET_PATTERNS = [
|
|
28
33
|
/AWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+]{35,}/i,
|
|
29
34
|
/(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['"]?[^\s'"]{12,}/i,
|
|
@@ -78,16 +83,24 @@ function snapshotId() {
|
|
|
78
83
|
return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
export function regenerateSnapshotIdentity(snapshot: Snapshot): Snapshot {
|
|
87
|
+
return {
|
|
88
|
+
...snapshot,
|
|
89
|
+
id: snapshotId(),
|
|
90
|
+
createdAt: new Date().toISOString(),
|
|
91
|
+
machine: os.hostname(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
export async function createSnapshot(
|
|
82
96
|
profile: string,
|
|
83
97
|
options: SnapshotOptions = {},
|
|
84
98
|
): Promise<Snapshot> {
|
|
85
|
-
const
|
|
99
|
+
const include = effectiveInclude(options);
|
|
100
|
+
const syncSessions = include.includes("sessions");
|
|
86
101
|
const files = await collectFiles(agentDir(), {
|
|
87
|
-
|
|
88
|
-
syncSessions,
|
|
102
|
+
include,
|
|
89
103
|
sessionDir: options.sessionDir ?? (await configuredSessionDir()),
|
|
90
|
-
extraFiles: options.extraFiles,
|
|
91
104
|
});
|
|
92
105
|
return {
|
|
93
106
|
version: VERSION,
|
|
@@ -100,13 +113,23 @@ export async function createSnapshot(
|
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
115
|
|
|
116
|
+
function effectiveInclude(options: SnapshotOptions) {
|
|
117
|
+
if (options.include) return options.include;
|
|
118
|
+
return [
|
|
119
|
+
...normalizeSyncFiles(options.syncFiles),
|
|
120
|
+
...normalizeExtraFiles(options.extraFiles),
|
|
121
|
+
...(options.syncSessions ? ["sessions"] : []),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
export async function collectFiles(
|
|
104
126
|
root: string,
|
|
105
127
|
options: SnapshotOptions = {},
|
|
106
128
|
): Promise<SnapshotFile[]> {
|
|
107
129
|
const results: SnapshotFile[] = [];
|
|
108
130
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
109
|
-
const
|
|
131
|
+
const selection = syncIncludeSelection(effectiveInclude(options));
|
|
132
|
+
const selectedFiles = new Set<string>(selection.builtIns);
|
|
110
133
|
for (const entry of entries) {
|
|
111
134
|
if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name) && selectedFiles.has(entry.name)) {
|
|
112
135
|
await collectDirectory(results, root, entry.name);
|
|
@@ -117,11 +140,10 @@ export async function collectFiles(
|
|
|
117
140
|
const entry = selectTopLevelFileEntry(entries, fileName);
|
|
118
141
|
if (entry) await addFile(results, root, entry.name, fileName);
|
|
119
142
|
}
|
|
120
|
-
for (const
|
|
121
|
-
|
|
122
|
-
if (entry) await addFile(results, root, entry.name, extraFileName);
|
|
143
|
+
for (const relativePath of selection.custom) {
|
|
144
|
+
await collectIncludedPath(results, root, relativePath);
|
|
123
145
|
}
|
|
124
|
-
if (
|
|
146
|
+
if (selection.sessions) {
|
|
125
147
|
try {
|
|
126
148
|
await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
|
|
127
149
|
sessionsOnly: true,
|
|
@@ -134,6 +156,22 @@ export async function collectFiles(
|
|
|
134
156
|
return results.sort((left, right) => left.path.localeCompare(right.path));
|
|
135
157
|
}
|
|
136
158
|
|
|
159
|
+
async function collectIncludedPath(results: SnapshotFile[], root: string, relativePath: string) {
|
|
160
|
+
const absolutePath = safeJoin(root, relativePath);
|
|
161
|
+
try {
|
|
162
|
+
const stat = await fs.lstat(absolutePath);
|
|
163
|
+
if (stat.isFile()) await addFile(results, root, relativePath);
|
|
164
|
+
else if (stat.isDirectory()) await collectDirectory(results, root, relativePath);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
167
|
+
if (!relativePath.includes("/")) {
|
|
168
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
169
|
+
const entry = selectTopLevelFileEntry(entries, relativePath);
|
|
170
|
+
if (entry) await addFile(results, root, entry.name, relativePath);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
137
175
|
async function collectDirectory(
|
|
138
176
|
results: SnapshotFile[],
|
|
139
177
|
root: string,
|
|
@@ -216,15 +254,15 @@ export function snapshotIncludesSessions(snapshot: Snapshot) {
|
|
|
216
254
|
|
|
217
255
|
export function filterSnapshotForConfigPolicy(
|
|
218
256
|
snapshot: Snapshot,
|
|
219
|
-
config:
|
|
257
|
+
config: SyncSelectionConfig,
|
|
220
258
|
options: { regenerateId?: boolean } = {},
|
|
221
259
|
) {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
260
|
+
const include = includeFromSelectionConfig(config);
|
|
261
|
+
const includePaths = customIncludePathsByLower(include);
|
|
224
262
|
const filtered = {
|
|
225
263
|
...snapshot,
|
|
226
|
-
syncSessions:
|
|
227
|
-
files: canonicalizeSnapshotFilesForConfig(snapshot.files, config,
|
|
264
|
+
syncSessions: include.includes("sessions") ? snapshot.syncSessions : false,
|
|
265
|
+
files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, includePaths),
|
|
228
266
|
};
|
|
229
267
|
if (!options.regenerateId || snapshotsMatch(snapshot, filtered)) return filtered;
|
|
230
268
|
return {
|
|
@@ -237,9 +275,8 @@ export function filterSnapshotForConfigPolicy(
|
|
|
237
275
|
|
|
238
276
|
function canonicalizeSnapshotFilesForConfig(
|
|
239
277
|
files: SnapshotFile[],
|
|
240
|
-
config:
|
|
241
|
-
|
|
242
|
-
extraFilePaths: Map<string, string>,
|
|
278
|
+
config: SyncSelectionConfig,
|
|
279
|
+
includePaths: Map<string, string>,
|
|
243
280
|
) {
|
|
244
281
|
const configuredFiles: SnapshotFile[] = [];
|
|
245
282
|
const extraCandidates = new Map<
|
|
@@ -248,17 +285,14 @@ function canonicalizeSnapshotFilesForConfig(
|
|
|
248
285
|
>();
|
|
249
286
|
for (const file of files) {
|
|
250
287
|
const normalized = toPosix(file.path);
|
|
251
|
-
if (
|
|
252
|
-
!isSafeSnapshotPath(file.path) ||
|
|
253
|
-
!isConfiguredSnapshotPath(normalized, config, extraFiles)
|
|
254
|
-
) {
|
|
288
|
+
if (!isSafeSnapshotPath(file.path) || !isConfiguredSnapshotPath(normalized, config)) {
|
|
255
289
|
continue;
|
|
256
290
|
}
|
|
257
291
|
if (normalized.includes("/")) {
|
|
258
292
|
configuredFiles.push(normalized === file.path ? file : { ...file, path: normalized });
|
|
259
293
|
continue;
|
|
260
294
|
}
|
|
261
|
-
const topLevelPath = canonicalSnapshotPathForConfig(normalized,
|
|
295
|
+
const topLevelPath = canonicalSnapshotPathForConfig(normalized, includePaths);
|
|
262
296
|
const candidate = {
|
|
263
297
|
exact: normalized === topLevelPath,
|
|
264
298
|
file: { ...file, path: topLevelPath },
|
|
@@ -315,12 +349,9 @@ export function scanSnapshot(snapshot: Snapshot) {
|
|
|
315
349
|
export function mergeRemotePreservedFiles(
|
|
316
350
|
local: Snapshot,
|
|
317
351
|
remote: Snapshot,
|
|
318
|
-
config:
|
|
352
|
+
config: SyncSelectionConfig,
|
|
319
353
|
) {
|
|
320
354
|
const localPathNames = new Set(local.files.map((file) => file.path.toLowerCase()));
|
|
321
|
-
const configuredExtraFiles = new Set(
|
|
322
|
-
normalizeExtraFiles(config.extraFiles).map((file) => file.toLowerCase()),
|
|
323
|
-
);
|
|
324
355
|
const preservedPathNames = new Set<string>();
|
|
325
356
|
const preserved = remote.files.filter((file) => {
|
|
326
357
|
const normalized = toPosix(file.path);
|
|
@@ -329,7 +360,7 @@ export function mergeRemotePreservedFiles(
|
|
|
329
360
|
localPathNames.has(lower) ||
|
|
330
361
|
preservedPathNames.has(lower) ||
|
|
331
362
|
!isSafeSnapshotPath(file.path) ||
|
|
332
|
-
isConfiguredSnapshotPath(normalized, config
|
|
363
|
+
isConfiguredSnapshotPath(normalized, config) ||
|
|
333
364
|
!isPreservableUnmanagedSnapshotPath(normalized)
|
|
334
365
|
) {
|
|
335
366
|
return false;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isCloudflareR2Endpoint, readLocalConfigObject } from "./config.js";
|
|
3
|
+
import { showAddGitStorageProfile, showEditGitStorageProfile } from "./git-ui.js";
|
|
4
|
+
import { errorMessage, ownRecord, requiredInput, safeTerminalText } from "./manager-helpers.js";
|
|
5
|
+
import {
|
|
6
|
+
applyS3CredentialUpdate,
|
|
7
|
+
chooseS3Credentials,
|
|
8
|
+
chooseS3CredentialUpdate,
|
|
9
|
+
} from "./s3-credentials-ui.js";
|
|
10
|
+
import {
|
|
11
|
+
addStorageConnection,
|
|
12
|
+
removeStorageConnection,
|
|
13
|
+
updateStorageConnection,
|
|
14
|
+
} from "./settings-management.js";
|
|
15
|
+
import { showAddWebDavStorageProfile, showEditWebDavStorageProfile } from "./webdav-ui.js";
|
|
16
|
+
|
|
17
|
+
const BACK = "Back";
|
|
18
|
+
|
|
19
|
+
export async function showStorageConnections(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
20
|
+
while (!signal?.aborted) {
|
|
21
|
+
const raw = await readLocalConfigObject();
|
|
22
|
+
if (signal?.aborted) return;
|
|
23
|
+
if (raw?.version !== 3) {
|
|
24
|
+
ctx.ui.notify("Create version 3 settings before managing storage connections.", "info");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const profiles = ownRecord(raw.storageConnections) ?? {};
|
|
28
|
+
const labels = new Map(
|
|
29
|
+
Object.keys(profiles)
|
|
30
|
+
.sort((left, right) => left.localeCompare(right))
|
|
31
|
+
.map((name) => [safeTerminalText(name), name]),
|
|
32
|
+
);
|
|
33
|
+
const selected = await ctx.ui.select(
|
|
34
|
+
"Storage connections",
|
|
35
|
+
["Add storage connection", ...labels.keys(), BACK],
|
|
36
|
+
{ signal },
|
|
37
|
+
);
|
|
38
|
+
if (signal?.aborted || !selected || selected === BACK) return;
|
|
39
|
+
if (selected === "Add storage connection") {
|
|
40
|
+
try {
|
|
41
|
+
await showAddStorageConnection(ctx, signal);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (signal?.aborted) return;
|
|
44
|
+
ctx.ui.notify(
|
|
45
|
+
`Storage connection was not added: ${safeTerminalText(errorMessage(error))} Retry from Add storage connection.`,
|
|
46
|
+
"error",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const name = labels.get(selected);
|
|
52
|
+
if (name) await showStorageConnectionDetail(ctx, name, signal);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function showStorageConnectionDetail(
|
|
57
|
+
ctx: ExtensionCommandContext,
|
|
58
|
+
name: string,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
) {
|
|
61
|
+
while (!signal?.aborted) {
|
|
62
|
+
const raw = await readLocalConfigObject();
|
|
63
|
+
if (signal?.aborted) return;
|
|
64
|
+
const profiles = ownRecord(raw?.storageConnections) ?? {};
|
|
65
|
+
const profile = ownRecord(profiles[name]);
|
|
66
|
+
if (!profile) {
|
|
67
|
+
ctx.ui.notify(`Storage connection “${safeTerminalText(name)}” no longer exists.`, "warning");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const usedBy = referencingSetups(raw, name);
|
|
71
|
+
const selected = await ctx.ui.select(
|
|
72
|
+
[
|
|
73
|
+
`Storage connection “${safeTerminalText(name)}”`,
|
|
74
|
+
"",
|
|
75
|
+
`Type: ${connectionType(profile)}`,
|
|
76
|
+
`Endpoint: ${connectionEndpoint(profile)}`,
|
|
77
|
+
`Credentials: ${credentialSource(profile)}`,
|
|
78
|
+
`Used by: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "No sync setups"}`,
|
|
79
|
+
...(usedBy.length > 0
|
|
80
|
+
? ["Remove unavailable: edit or remove the listed sync setups first."]
|
|
81
|
+
: []),
|
|
82
|
+
].join("\n"),
|
|
83
|
+
[
|
|
84
|
+
"Edit storage connection…",
|
|
85
|
+
...(usedBy.length === 0 ? ["Remove storage connection…"] : []),
|
|
86
|
+
BACK,
|
|
87
|
+
],
|
|
88
|
+
{ signal },
|
|
89
|
+
);
|
|
90
|
+
if (signal?.aborted || !selected || selected === BACK) return;
|
|
91
|
+
try {
|
|
92
|
+
if (selected === "Remove storage connection…") {
|
|
93
|
+
const confirmed = await ctx.ui.confirm(
|
|
94
|
+
"Remove storage connection?",
|
|
95
|
+
`Remove local storage connection “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
|
|
96
|
+
{ signal },
|
|
97
|
+
);
|
|
98
|
+
if (!confirmed || signal?.aborted) continue;
|
|
99
|
+
await removeStorageConnection(name, signal);
|
|
100
|
+
ctx.ui.notify(`Removed storage connection “${safeTerminalText(name)}”.`, "info");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await editStorageConnection(ctx, name, profile, usedBy, signal);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (signal?.aborted) return;
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
`Storage connection “${safeTerminalText(name)}” was not changed: ${safeTerminalText(errorMessage(error))} Reopen it and retry.`,
|
|
108
|
+
"error",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function editStorageConnection(
|
|
115
|
+
ctx: ExtensionCommandContext,
|
|
116
|
+
name: string,
|
|
117
|
+
profile: Record<string, unknown>,
|
|
118
|
+
usedBy: string[],
|
|
119
|
+
signal?: AbortSignal,
|
|
120
|
+
) {
|
|
121
|
+
if (ctx.mode !== "tui") {
|
|
122
|
+
ctx.ui.notify(
|
|
123
|
+
"Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
|
|
124
|
+
"warning",
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (profile.type === "webdav") {
|
|
129
|
+
await showEditWebDavStorageProfile(
|
|
130
|
+
ctx,
|
|
131
|
+
name,
|
|
132
|
+
{ ...profile, kind: "webdav", ...(ownRecord(profile.credentials) ?? {}) },
|
|
133
|
+
signal,
|
|
134
|
+
usedBy,
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (profile.type === "git") {
|
|
139
|
+
await showEditGitStorageProfile(ctx, name, { ...profile, kind: "git" }, signal, usedBy);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const endpoint = await requiredInput(
|
|
143
|
+
ctx,
|
|
144
|
+
"Endpoint",
|
|
145
|
+
String(profile.endpoint ?? "https://s3.example.com"),
|
|
146
|
+
signal,
|
|
147
|
+
);
|
|
148
|
+
if (!endpoint || signal?.aborted) return;
|
|
149
|
+
const region = await requiredInput(ctx, "Region", String(profile.region ?? "auto"), signal);
|
|
150
|
+
if (!region || signal?.aborted) return;
|
|
151
|
+
const storedCredentials = ownRecord(profile.credentials) ?? {};
|
|
152
|
+
const credentials = await chooseS3CredentialUpdate(
|
|
153
|
+
ctx,
|
|
154
|
+
{ ...profile, ...storedCredentials },
|
|
155
|
+
signal,
|
|
156
|
+
);
|
|
157
|
+
if (!credentials || signal?.aborted) return;
|
|
158
|
+
const save = await ctx.ui.select(
|
|
159
|
+
[
|
|
160
|
+
"Review storage connection",
|
|
161
|
+
"",
|
|
162
|
+
`Storage connection: ${safeTerminalText(name)}`,
|
|
163
|
+
`Endpoint: ${safeTerminalText(String(profile.endpoint ?? "missing"))} → ${safeTerminalText(endpoint)}`,
|
|
164
|
+
`Region: ${safeTerminalText(String(profile.region ?? "auto"))} → ${safeTerminalText(region)}`,
|
|
165
|
+
`Credentials: ${safeTerminalText(credentials.summary)}`,
|
|
166
|
+
`Affected sync setups: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "None"}`,
|
|
167
|
+
"Saving changes future storage access for every affected setup; it does not move remote data.",
|
|
168
|
+
].join("\n"),
|
|
169
|
+
["Save storage connection", "Cancel"],
|
|
170
|
+
{ signal },
|
|
171
|
+
);
|
|
172
|
+
if (save !== "Save storage connection" || signal?.aborted) return;
|
|
173
|
+
await updateStorageConnection(
|
|
174
|
+
name,
|
|
175
|
+
(current) => {
|
|
176
|
+
if (current.type !== "s3") {
|
|
177
|
+
throw new Error("Storage connection type changed; reopen it.");
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
...current,
|
|
181
|
+
endpoint,
|
|
182
|
+
region,
|
|
183
|
+
credentials: applyS3CredentialUpdate(
|
|
184
|
+
current.credentials,
|
|
185
|
+
credentials,
|
|
186
|
+
) as typeof current.credentials,
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
usedBy,
|
|
190
|
+
signal,
|
|
191
|
+
);
|
|
192
|
+
if (signal?.aborted) return;
|
|
193
|
+
ctx.ui.notify(`Saved storage connection “${safeTerminalText(name)}”.`, "info");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function showAddStorageConnection(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
197
|
+
if (ctx.mode !== "tui") {
|
|
198
|
+
ctx.ui.notify(
|
|
199
|
+
"Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
|
|
200
|
+
"warning",
|
|
201
|
+
);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const preset = await ctx.ui.select(
|
|
205
|
+
"Storage type",
|
|
206
|
+
["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
|
|
207
|
+
{ signal },
|
|
208
|
+
);
|
|
209
|
+
if (signal?.aborted || !preset || preset === "Cancel") return false;
|
|
210
|
+
if (preset === "WebDAV") return showAddWebDavStorageProfile(ctx, signal);
|
|
211
|
+
if (preset === "Git") return showAddGitStorageProfile(ctx, signal);
|
|
212
|
+
const name = await requiredInput(
|
|
213
|
+
ctx,
|
|
214
|
+
"Name this storage connection",
|
|
215
|
+
preset === "Cloudflare R2" ? "r2" : "s3",
|
|
216
|
+
signal,
|
|
217
|
+
);
|
|
218
|
+
if (!name || signal?.aborted) return false;
|
|
219
|
+
const endpoint = await requiredInput(
|
|
220
|
+
ctx,
|
|
221
|
+
"Endpoint",
|
|
222
|
+
preset === "Cloudflare R2"
|
|
223
|
+
? "https://<account-id>.r2.cloudflarestorage.com"
|
|
224
|
+
: "https://s3.example.com",
|
|
225
|
+
signal,
|
|
226
|
+
);
|
|
227
|
+
if (!endpoint || signal?.aborted) return false;
|
|
228
|
+
const region =
|
|
229
|
+
preset === "Cloudflare R2" ? "auto" : await requiredInput(ctx, "Region", "us-east-1", signal);
|
|
230
|
+
if (!region || signal?.aborted) return false;
|
|
231
|
+
const credentials = await chooseS3Credentials(ctx, signal);
|
|
232
|
+
if (!credentials || signal?.aborted) return false;
|
|
233
|
+
const save = await ctx.ui.select(
|
|
234
|
+
[
|
|
235
|
+
"Review storage connection",
|
|
236
|
+
"",
|
|
237
|
+
`Name: ${safeTerminalText(name)}`,
|
|
238
|
+
`Type: ${preset}`,
|
|
239
|
+
`Endpoint: ${safeTerminalText(endpoint)}`,
|
|
240
|
+
`Region: ${safeTerminalText(region)}`,
|
|
241
|
+
`Credentials: ${safeTerminalText(credentials.summary)}`,
|
|
242
|
+
"Adding a connection does not contact remote storage or start syncing.",
|
|
243
|
+
].join("\n"),
|
|
244
|
+
["Add storage connection", "Cancel"],
|
|
245
|
+
{ signal },
|
|
246
|
+
);
|
|
247
|
+
if (save !== "Add storage connection" || signal?.aborted) return false;
|
|
248
|
+
await addStorageConnection(
|
|
249
|
+
name,
|
|
250
|
+
{
|
|
251
|
+
type: "s3",
|
|
252
|
+
endpoint,
|
|
253
|
+
region,
|
|
254
|
+
credentials: {
|
|
255
|
+
accessKeyId: credentials.profileFields.accessKeyId ?? "",
|
|
256
|
+
secretAccessKey: credentials.profileFields.secretAccessKey ?? "",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
signal,
|
|
260
|
+
);
|
|
261
|
+
if (signal?.aborted) return true;
|
|
262
|
+
ctx.ui.notify(`Added storage connection “${safeTerminalText(name)}”.`, "info");
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function referencingSetups(raw: Record<string, unknown> | undefined, connection: string) {
|
|
267
|
+
return Object.entries(ownRecord(raw?.syncSetups) ?? {})
|
|
268
|
+
.filter(([, value]) => ownRecord(ownRecord(value)?.storage)?.connection === connection)
|
|
269
|
+
.map(([name]) => name)
|
|
270
|
+
.sort((left, right) => left.localeCompare(right));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function connectionType(profile: Record<string, unknown>) {
|
|
274
|
+
if (profile.type === "git") return "Git";
|
|
275
|
+
if (profile.type === "webdav") return "WebDAV";
|
|
276
|
+
if (
|
|
277
|
+
profile.type === "s3" &&
|
|
278
|
+
typeof profile.endpoint === "string" &&
|
|
279
|
+
isCloudflareR2Endpoint(profile.endpoint)
|
|
280
|
+
) {
|
|
281
|
+
return "Cloudflare R2";
|
|
282
|
+
}
|
|
283
|
+
return "S3-compatible";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function connectionEndpoint(profile: Record<string, unknown>) {
|
|
287
|
+
const value =
|
|
288
|
+
profile.type === "git"
|
|
289
|
+
? profile.remote
|
|
290
|
+
: profile.type === "webdav"
|
|
291
|
+
? profile.url
|
|
292
|
+
: profile.endpoint;
|
|
293
|
+
if (typeof value !== "string" || value.length === 0) return "Missing";
|
|
294
|
+
if (profile.type === "git") return safeTerminalText(value);
|
|
295
|
+
try {
|
|
296
|
+
return safeTerminalText(new URL(value).host);
|
|
297
|
+
} catch {
|
|
298
|
+
return "Invalid";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function credentialSource(profile: Record<string, unknown>) {
|
|
303
|
+
if (profile.type === "git") return "Git credential helper or SSH configuration";
|
|
304
|
+
const credentials = ownRecord(profile.credentials);
|
|
305
|
+
if (profile.type === "webdav") return credentials?.password ? "Settings file" : "Missing";
|
|
306
|
+
if (credentials?.accessKeyId && credentials.secretAccessKey) return "Settings file";
|
|
307
|
+
return "Missing";
|
|
308
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Snapshot } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type PublicationCapability =
|
|
4
|
+
| "read-check-write-verify"
|
|
5
|
+
| "conditional-required"
|
|
6
|
+
| "atomic-conditional"
|
|
7
|
+
| "lease-protected";
|
|
8
|
+
|
|
9
|
+
export interface RemoteHead {
|
|
10
|
+
/** Backend-owned reference used only to retrieve this immutable snapshot. */
|
|
11
|
+
snapshotRef: string;
|
|
12
|
+
/** Backend-neutral snapshot content identity retained in local sync state. */
|
|
13
|
+
snapshotId: string;
|
|
14
|
+
revision: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
machine: string;
|
|
17
|
+
syncSessions: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RemoteHistoryEntry {
|
|
21
|
+
snapshotRef: string;
|
|
22
|
+
snapshotId: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
machine: string;
|
|
25
|
+
syncSessions: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ExpectedRemoteHead = { kind: "missing" } | { kind: "revision"; revision: string };
|
|
29
|
+
|
|
30
|
+
export interface PublishSnapshotOptions {
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
/** Called at the backend's active-head commit boundary. */
|
|
33
|
+
onCommit?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PublishSnapshotResult {
|
|
37
|
+
head: RemoteHead;
|
|
38
|
+
warnings: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BackendDiagnostic {
|
|
42
|
+
key: string;
|
|
43
|
+
level: "info" | "warning" | "error";
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SyncBackend {
|
|
48
|
+
readonly identity: string;
|
|
49
|
+
readonly destination: string;
|
|
50
|
+
readonly capability: PublicationCapability;
|
|
51
|
+
/** Compare opaque revisions produced by this backend identity. */
|
|
52
|
+
sameRevision(left: string, right: string): boolean;
|
|
53
|
+
readHead(signal?: AbortSignal): Promise<RemoteHead | undefined>;
|
|
54
|
+
readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot>;
|
|
55
|
+
publishSnapshot(
|
|
56
|
+
snapshot: Snapshot,
|
|
57
|
+
expected: ExpectedRemoteHead,
|
|
58
|
+
options?: PublishSnapshotOptions,
|
|
59
|
+
): Promise<PublishSnapshotResult>;
|
|
60
|
+
listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]>;
|
|
61
|
+
diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class SyncBackendConflictError extends Error {
|
|
65
|
+
readonly code = "SYNC_BACKEND_CONFLICT";
|
|
66
|
+
readonly phase: "before-commit" | "after-commit";
|
|
67
|
+
readonly currentHead?: RemoteHead;
|
|
68
|
+
readonly candidateMayHaveBeenActive: boolean;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
message: string,
|
|
72
|
+
options: ErrorOptions & {
|
|
73
|
+
phase?: "before-commit" | "after-commit";
|
|
74
|
+
currentHead?: RemoteHead;
|
|
75
|
+
candidateMayHaveBeenActive?: boolean;
|
|
76
|
+
} = {},
|
|
77
|
+
) {
|
|
78
|
+
super(message, options);
|
|
79
|
+
this.name = "SyncBackendConflictError";
|
|
80
|
+
this.phase = options.phase ?? "before-commit";
|
|
81
|
+
this.currentHead = options.currentHead;
|
|
82
|
+
this.candidateMayHaveBeenActive = options.candidateMayHaveBeenActive ?? false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class SyncBackendPublicationOutcomeUnknownError extends Error {
|
|
87
|
+
readonly code = "SYNC_BACKEND_PUBLICATION_OUTCOME_UNKNOWN";
|
|
88
|
+
|
|
89
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
90
|
+
super(message, options);
|
|
91
|
+
this.name = "SyncBackendPublicationOutcomeUnknownError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function expectedRemoteHead(head: RemoteHead | undefined): ExpectedRemoteHead {
|
|
96
|
+
return head ? { kind: "revision", revision: head.revision } : { kind: "missing" };
|
|
97
|
+
}
|