@narumitw/pi-sync 0.49.15 → 0.50.1
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 +49 -16
- package/dist/chunks/attention-widget-Z7G3SYYJ.ts +9 -0
- package/dist/chunks/attention-widget-Z7G3SYYJ.ts.map +7 -0
- package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
- package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
- package/dist/chunks/{chunk-ARN2672F.ts → chunk-4CHTZMEO.ts} +23 -55
- package/dist/chunks/chunk-4CHTZMEO.ts.map +7 -0
- package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
- package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
- package/dist/chunks/{chunk-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
- package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
- package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
- package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
- package/dist/chunks/chunk-CCSN3F47.ts +51 -0
- package/dist/chunks/chunk-CCSN3F47.ts.map +7 -0
- package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
- package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
- package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
- package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
- package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
- package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
- package/dist/chunks/{chunk-WSOBYHIZ.ts → chunk-ETTRJDNP.ts} +4 -4
- package/dist/chunks/chunk-ETTRJDNP.ts.map +7 -0
- package/dist/chunks/{chunk-44LPGQTI.ts → chunk-G4RNQQZS.ts} +9 -7
- package/dist/chunks/chunk-G4RNQQZS.ts.map +7 -0
- package/dist/chunks/{chunk-5LZALPCD.ts → chunk-HUDQATCL.ts} +25 -25
- package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
- package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-IJ6FJFA5.ts} +9 -9
- package/dist/chunks/chunk-IJ6FJFA5.ts.map +7 -0
- package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
- package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
- package/dist/chunks/chunk-KW7YQEZN.ts +1255 -0
- package/dist/chunks/chunk-KW7YQEZN.ts.map +7 -0
- package/dist/chunks/{chunk-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -8
- package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
- package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
- package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
- package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
- package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
- package/dist/chunks/{chunk-HVUSHMVL.ts → chunk-OEX4ABY6.ts} +39 -37
- package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
- package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
- package/dist/chunks/chunk-S7YBZR42.ts +146 -0
- package/dist/chunks/chunk-S7YBZR42.ts.map +7 -0
- package/dist/chunks/{chunk-GLQLN5GV.ts → chunk-SL64RLXR.ts} +8 -6
- package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
- package/dist/chunks/chunk-SZWBGPID.ts +57 -0
- package/dist/chunks/chunk-SZWBGPID.ts.map +7 -0
- package/dist/chunks/chunk-T2B5IU74.ts +25 -0
- package/dist/chunks/chunk-T2B5IU74.ts.map +7 -0
- package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
- package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
- package/dist/chunks/chunk-THMF4BVL.ts +18 -0
- package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
- package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
- package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
- package/dist/chunks/{file-selection-RKEHALNQ.ts → file-selection-FTTKAOYX.ts} +10 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
- package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-GMVS4PD4.ts} +39 -14
- package/dist/chunks/git-backend-GMVS4PD4.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +22 -0
- package/dist/chunks/manager-ui-R3XPUFFS.ts +1680 -0
- package/dist/chunks/manager-ui-R3XPUFFS.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +21 -0
- package/dist/chunks/{s3-backend-552LXHE4.ts → s3-backend-66O4FGBH.ts} +94 -16
- package/dist/chunks/s3-backend-66O4FGBH.ts.map +7 -0
- package/dist/chunks/{setup-switch-5YDG5YGJ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
- package/dist/chunks/setup-wizard-IUB6ESEU.ts +89 -0
- package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +7 -0
- package/dist/chunks/{snapshot-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
- package/dist/chunks/sync-inspection-CST2KL65.ts +20 -0
- package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-LBX3GKCM.ts} +369 -289
- package/dist/chunks/sync-operations-LBX3GKCM.ts.map +7 -0
- package/dist/chunks/{sync-state-SRN6YRBK.ts → sync-state-Q2FTXTW6.ts} +6 -6
- package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
- package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
- package/dist/index.ts +476 -341
- package/dist/index.ts.map +4 -4
- package/docs/settings.md +30 -6
- package/package.json +1 -1
- package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
- package/src/backends/backend-identity.ts +40 -0
- package/src/backends/backend-types.ts +81 -0
- package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
- package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
- package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
- package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
- package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
- package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
- package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
- package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
- package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
- package/src/commands/command-execution.ts +330 -0
- package/src/commands/command-handler.ts +201 -0
- package/src/commands/command-types.ts +18 -0
- package/src/{command.ts → commands/command.ts} +2 -1
- package/src/commands/setup-completions.ts +6 -0
- package/src/settings/config.ts +238 -0
- package/src/{settings-management.ts → settings/settings-management.ts} +39 -14
- package/src/settings/settings-store.ts +109 -0
- package/src/settings/settings-types.ts +128 -0
- package/src/settings/settings-validation.ts +443 -0
- package/src/snapshot/session-paths.ts +84 -0
- package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
- package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
- package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
- package/src/snapshot/snapshot-types.ts +38 -0
- package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
- package/src/state/json-file.ts +20 -0
- package/src/{lock.ts → state/lock.ts} +3 -2
- package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
- package/src/state/state-types.ts +20 -0
- package/src/state/sync-state-store.ts +62 -0
- package/src/sync/automatic-sync.ts +103 -0
- package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
- package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
- package/src/sync/signals.ts +10 -0
- package/src/sync/startup-check.ts +128 -0
- package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
- package/src/sync/sync-error-guidance.ts +53 -0
- package/src/sync/sync-inspection.ts +51 -0
- package/src/sync/sync-loaders.ts +54 -0
- package/src/{sync-operations.ts → sync/sync-mutations.ts} +34 -325
- package/src/sync/sync-operations.ts +12 -0
- package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
- package/src/sync/sync-queries.ts +235 -0
- package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
- package/src/sync-extension.ts +64 -708
- package/src/sync.ts +8 -8
- package/src/types.ts +47 -286
- package/src/ui/attention-widget.ts +1 -0
- package/src/ui/automatic-sync-summary.ts +6 -0
- package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
- package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
- package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
- package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
- package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
- package/src/ui/manager-ui.ts +386 -0
- package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
- package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
- package/src/{settings-ui.ts → ui/settings-ui.ts} +17 -13
- package/src/ui/setup/git-ui.ts +293 -0
- package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
- package/src/ui/setup/s3-ui.ts +217 -0
- package/src/ui/setup/setup-actions.ts +127 -0
- package/src/ui/setup/setup-location-ui.ts +167 -0
- package/src/ui/setup/setup-name-ui.ts +34 -0
- package/src/ui/setup/setup-prompts.ts +92 -0
- package/src/ui/setup/setup-review.ts +70 -0
- package/src/ui/setup/setup-switcher.ts +181 -0
- package/src/ui/setup/setup-wizard.ts +39 -0
- package/src/ui/setup/text-input.ts +78 -0
- package/src/ui/setup/webdav-ui.ts +330 -0
- package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
- package/src/ui/sync-attention.ts +187 -0
- package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
- package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
- package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
- package/src/ui/terminal-text.ts +4 -0
- package/dist/chunks/chunk-44LPGQTI.ts.map +0 -7
- package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
- package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
- package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
- package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
- package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
- package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
- package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
- package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
- package/dist/chunks/chunk-E4COSV32.ts +0 -82
- package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
- package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
- package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
- package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
- package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
- package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
- package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
- package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
- package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
- package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
- package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
- package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
- package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
- package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
- package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
- package/src/config.ts +0 -923
- package/src/git-ui.ts +0 -307
- package/src/manager-helpers.ts +0 -64
- package/src/manager-ui.ts +0 -997
- package/src/sync-attention.ts +0 -100
- package/src/webdav-ui.ts +0 -421
- /package/dist/chunks/{manager-result-dispatcher-LWPYJAD5.ts.map → manager-result-dispatcher-FKIBM3GO.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-D7RBRJNL.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
- /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
- /package/dist/chunks/{sync-state-SRN6YRBK.ts.map → sync-inspection-CST2KL65.ts.map} +0 -0
- /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
- /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
- /package/src/{config-file.ts → settings/config-file.ts} +0 -0
- /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
- /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
- /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
- /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
- /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
- /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import type { Snapshot, SnapshotSelection } from "../../snapshot/snapshot-types.js";
|
|
3
|
+
import { portableSnapshotSelection, snapshotSelectionInclude } from "../../sync/sync-policy.js";
|
|
4
4
|
|
|
5
5
|
export const GIT_MANIFEST_VERSION = 2;
|
|
6
6
|
export const MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { encodeKey, posixJoin } from "../../paths.js";
|
|
3
|
+
import { sessionTokenWarnings } from "../../settings/config.js";
|
|
4
|
+
import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
|
|
5
|
+
import type { Snapshot } from "../../snapshot/snapshot-types.js";
|
|
6
|
+
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
7
|
+
import { portableSnapshotSelection } from "../../sync/sync-policy.js";
|
|
8
|
+
import type { LatestPointer, RemoteObject, ResolvedS3Backend } from "../backend-types.js";
|
|
6
9
|
import {
|
|
7
10
|
type BackendDiagnostic,
|
|
8
11
|
type ExpectedRemoteHead,
|
|
@@ -13,9 +16,8 @@ import {
|
|
|
13
16
|
type SyncBackend,
|
|
14
17
|
SyncBackendConflictError,
|
|
15
18
|
SyncBackendPublicationOutcomeUnknownError,
|
|
16
|
-
} from "
|
|
17
|
-
import {
|
|
18
|
-
import type { LatestPointer, RemoteObject, ResolvedS3Backend, Snapshot } from "./types.js";
|
|
19
|
+
} from "../sync-backend.js";
|
|
20
|
+
import { S3Client, S3ObjectAlreadyExistsError } from "./s3-client.js";
|
|
19
21
|
|
|
20
22
|
const VERSION = 1;
|
|
21
23
|
const POST_COMMIT_TIMEOUT_MS = 30_000;
|
|
@@ -189,7 +191,7 @@ export class S3SyncBackend implements SyncBackend {
|
|
|
189
191
|
|
|
190
192
|
async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
|
|
191
193
|
throwIfAborted(signal);
|
|
192
|
-
|
|
194
|
+
const diagnostics: BackendDiagnostic[] = [
|
|
193
195
|
{
|
|
194
196
|
key: "s3-config",
|
|
195
197
|
level: "info",
|
|
@@ -201,6 +203,39 @@ export class S3SyncBackend implements SyncBackend {
|
|
|
201
203
|
message,
|
|
202
204
|
})),
|
|
203
205
|
];
|
|
206
|
+
const deadline = AbortSignal.timeout(10_000);
|
|
207
|
+
const probeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
208
|
+
try {
|
|
209
|
+
const result = await new S3Client(this.config, probeSignal).diagnoseRead(
|
|
210
|
+
latestKey(this.config),
|
|
211
|
+
);
|
|
212
|
+
throwIfAborted(probeSignal);
|
|
213
|
+
const messages = {
|
|
214
|
+
readable:
|
|
215
|
+
"s3 remote read: HTTP success. Snapshot contents and write access are not tested.",
|
|
216
|
+
"missing-key":
|
|
217
|
+
"s3 remote read: no snapshot at this path (NoSuchKey). Write access is not tested; verify the bucket and path before first sync.",
|
|
218
|
+
"missing-bucket":
|
|
219
|
+
"s3 remote read: bucket not found (NoSuchBucket). Create the bucket with your provider or edit the setup's bucket name.",
|
|
220
|
+
"unknown-404":
|
|
221
|
+
"s3 remote read: HTTP 404; cannot confirm whether the bucket or snapshot is missing. Verify the endpoint, bucket, and path with your provider. Write access is not tested.",
|
|
222
|
+
};
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
key: "s3-read",
|
|
225
|
+
level: result === "readable" ? "info" : "warning",
|
|
226
|
+
message: messages[result],
|
|
227
|
+
});
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throwIfAborted(signal);
|
|
230
|
+
diagnostics.push({
|
|
231
|
+
key: "s3-read",
|
|
232
|
+
level: "warning",
|
|
233
|
+
message: syncErrorGuidance(
|
|
234
|
+
deadline.aborted ? new Error("S3 diagnostic request timed out.") : error,
|
|
235
|
+
),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return diagnostics;
|
|
204
239
|
}
|
|
205
240
|
|
|
206
241
|
private async resolveChecksum(reference: string, signal?: AbortSignal) {
|
|
@@ -257,7 +292,7 @@ export function snapshotKey(config: ResolvedS3Backend, id: string) {
|
|
|
257
292
|
}
|
|
258
293
|
|
|
259
294
|
export function storageRoot(config: ResolvedS3Backend) {
|
|
260
|
-
return config.destination.prefix;
|
|
295
|
+
return config.destination.prefix === "./" ? "" : config.destination.prefix;
|
|
261
296
|
}
|
|
262
297
|
|
|
263
298
|
export function pointerFor(
|
|
@@ -444,6 +479,7 @@ function assertSafeDestination(config: ResolvedS3Backend) {
|
|
|
444
479
|
["prefix", config.destination.prefix, true],
|
|
445
480
|
["namespace", config.destination.namespace, false],
|
|
446
481
|
] as const) {
|
|
482
|
+
if (label === "prefix" && value === "./") continue;
|
|
447
483
|
if (
|
|
448
484
|
(!allowEmpty && value.length === 0) ||
|
|
449
485
|
value.includes("\\") ||
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
-
import { encodeKey, posixJoin } from "
|
|
4
|
-
import type { RemoteObject, ResolvedS3Backend } from "
|
|
3
|
+
import { encodeKey, posixJoin } from "../../paths.js";
|
|
4
|
+
import type { RemoteObject, ResolvedS3Backend } from "../backend-types.js";
|
|
5
5
|
|
|
6
6
|
const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
|
|
7
7
|
const MAX_SNAPSHOT_RESPONSE_BYTES = 256 * 1024 * 1024;
|
|
@@ -32,6 +32,16 @@ export class S3ObjectAlreadyExistsError extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export class S3HttpError extends Error {
|
|
36
|
+
constructor(
|
|
37
|
+
message: string,
|
|
38
|
+
readonly status: number,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "S3HttpError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
export class S3Client {
|
|
36
46
|
private config: ResolvedS3Backend;
|
|
37
47
|
private endpoint: URL;
|
|
@@ -50,6 +60,28 @@ export class S3Client {
|
|
|
50
60
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
/** Read-only access probe. A bare 404 cannot distinguish a missing key from a missing bucket. */
|
|
64
|
+
async diagnoseRead(
|
|
65
|
+
key: string,
|
|
66
|
+
): Promise<"readable" | "missing-key" | "missing-bucket" | "unknown-404"> {
|
|
67
|
+
this.signal?.throwIfAborted();
|
|
68
|
+
const response = await this.request("GET", key);
|
|
69
|
+
if (response.ok) {
|
|
70
|
+
await response.body?.cancel();
|
|
71
|
+
this.signal?.throwIfAborted();
|
|
72
|
+
return "readable";
|
|
73
|
+
}
|
|
74
|
+
const detail = await this.readErrorText(response);
|
|
75
|
+
this.signal?.throwIfAborted();
|
|
76
|
+
if (response.status === 404) {
|
|
77
|
+
const code = /<Code>\s*([A-Za-z]+)\s*<\/Code>/u.exec(detail)?.[1];
|
|
78
|
+
if (code === "NoSuchBucket") return "missing-bucket";
|
|
79
|
+
if (code === "NoSuchKey") return "missing-key";
|
|
80
|
+
return "unknown-404";
|
|
81
|
+
}
|
|
82
|
+
throw new S3HttpError(`S3 remote read failed (HTTP ${response.status}).`, response.status);
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
async getJson<T>(key: string): Promise<RemoteObject<T>> {
|
|
54
86
|
const maxAttempts = 3;
|
|
55
87
|
let lastError: unknown;
|
|
@@ -57,7 +89,10 @@ export class S3Client {
|
|
|
57
89
|
const object = await this.request("GET", key);
|
|
58
90
|
if (object.status === 404) return { missing: true };
|
|
59
91
|
if (!object.ok) {
|
|
60
|
-
throw new
|
|
92
|
+
throw new S3HttpError(
|
|
93
|
+
`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
|
|
94
|
+
object.status,
|
|
95
|
+
);
|
|
61
96
|
}
|
|
62
97
|
const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
|
|
63
98
|
// R2 can intermittently return an empty 200 body (read-after-write
|
|
@@ -87,7 +122,10 @@ export class S3Client {
|
|
|
87
122
|
const object = await this.request("GET", key);
|
|
88
123
|
if (object.status === 404) return { missing: true };
|
|
89
124
|
if (!object.ok) {
|
|
90
|
-
throw new
|
|
125
|
+
throw new S3HttpError(
|
|
126
|
+
`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
|
|
127
|
+
object.status,
|
|
128
|
+
);
|
|
91
129
|
}
|
|
92
130
|
const buffer = await readBoundedBuffer(
|
|
93
131
|
object,
|
|
@@ -130,7 +168,10 @@ export class S3Client {
|
|
|
130
168
|
throw new S3ObjectAlreadyExistsError(key);
|
|
131
169
|
}
|
|
132
170
|
if (!response.ok) {
|
|
133
|
-
throw new
|
|
171
|
+
throw new S3HttpError(
|
|
172
|
+
`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
|
|
173
|
+
response.status,
|
|
174
|
+
);
|
|
134
175
|
}
|
|
135
176
|
}
|
|
136
177
|
|
|
@@ -173,8 +214,13 @@ export class S3Client {
|
|
|
173
214
|
? undefined
|
|
174
215
|
: this.config.profile.sessionToken;
|
|
175
216
|
const response = await send(sessionToken);
|
|
176
|
-
|
|
177
|
-
|
|
217
|
+
try {
|
|
218
|
+
if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
await response.body?.cancel().catch(() => undefined);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
await response.body?.cancel();
|
|
178
224
|
const retry = await send(undefined);
|
|
179
225
|
if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
|
|
180
226
|
return retry;
|
|
@@ -246,6 +292,8 @@ async function readBoundedText(response: Response, limit: number, label: string)
|
|
|
246
292
|
async function readBoundedBuffer(response: Response, limit: number, label: string) {
|
|
247
293
|
const contentLength = Number(response.headers.get("content-length"));
|
|
248
294
|
if (Number.isFinite(contentLength) && contentLength > limit) {
|
|
295
|
+
// Do not await cancellation of a cloned stream before its sibling can be released.
|
|
296
|
+
void response.body?.cancel().catch(() => undefined);
|
|
249
297
|
throw new Error(`${label} exceeds the ${limit}-byte limit.`);
|
|
250
298
|
}
|
|
251
299
|
if (!response.body) return Buffer.alloc(0);
|
|
@@ -258,7 +306,7 @@ async function readBoundedBuffer(response: Response, limit: number, label: strin
|
|
|
258
306
|
if (done) break;
|
|
259
307
|
total += value.byteLength;
|
|
260
308
|
if (total > limit) {
|
|
261
|
-
|
|
309
|
+
void reader.cancel().catch(() => undefined);
|
|
262
310
|
throw new Error(`${label} exceeds the ${limit}-byte limit.`);
|
|
263
311
|
}
|
|
264
312
|
chunks.push(Buffer.from(value));
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { decodeSnapshot, encodeSnapshot } from "
|
|
2
|
+
import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
|
|
3
|
+
import type { Snapshot } from "../../snapshot/snapshot-types.js";
|
|
4
|
+
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
5
|
+
import { portableSnapshotSelection } from "../../sync/sync-policy.js";
|
|
6
|
+
import type { LatestPointer, RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
|
|
3
7
|
import {
|
|
4
8
|
type BackendDiagnostic,
|
|
5
9
|
type ExpectedRemoteHead,
|
|
@@ -10,9 +14,7 @@ import {
|
|
|
10
14
|
type SyncBackend,
|
|
11
15
|
SyncBackendConflictError,
|
|
12
16
|
SyncBackendPublicationOutcomeUnknownError,
|
|
13
|
-
} from "
|
|
14
|
-
import { portableSnapshotSelection } from "./sync-policy.js";
|
|
15
|
-
import type { LatestPointer, RemoteObject, ResolvedWebDavBackend, Snapshot } from "./types.js";
|
|
17
|
+
} from "../sync-backend.js";
|
|
16
18
|
import { WebDavClient, WebDavHttpError, WebDavPreconditionError } from "./webdav-client.js";
|
|
17
19
|
|
|
18
20
|
const VERSION = 1;
|
|
@@ -198,6 +200,12 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
198
200
|
level: "info",
|
|
199
201
|
message: `webdav URL/TLS/auth: configured (${webDavStorageLocation(this.config)})`,
|
|
200
202
|
},
|
|
203
|
+
{
|
|
204
|
+
key: "webdav-scope",
|
|
205
|
+
level: "info",
|
|
206
|
+
message:
|
|
207
|
+
"webdav check scope: temporary write/delete probe; repair the active snapshot's history entry if missing. No local content is applied.",
|
|
208
|
+
},
|
|
201
209
|
];
|
|
202
210
|
try {
|
|
203
211
|
await this.runCapabilityProbe(signal);
|
|
@@ -217,7 +225,7 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
217
225
|
diagnostics.push({
|
|
218
226
|
key: "webdav-probe",
|
|
219
227
|
level: "error",
|
|
220
|
-
message: `webdav publication is read-only until diagnostics pass: ${
|
|
228
|
+
message: `webdav publication is read-only until diagnostics pass: ${syncErrorGuidance(error)}`,
|
|
221
229
|
});
|
|
222
230
|
return diagnostics;
|
|
223
231
|
}
|
|
@@ -232,7 +240,7 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
232
240
|
diagnostics.push({
|
|
233
241
|
key: "webdav-history",
|
|
234
242
|
level: "error",
|
|
235
|
-
message: `webdav history repair failed: ${
|
|
243
|
+
message: `webdav history repair failed: ${syncErrorGuidance(error)}`,
|
|
236
244
|
});
|
|
237
245
|
}
|
|
238
246
|
return diagnostics;
|
|
@@ -250,7 +258,7 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
250
258
|
|
|
251
259
|
private async runCapabilityProbe(signal?: AbortSignal) {
|
|
252
260
|
throwIfAborted(signal);
|
|
253
|
-
const probeCollection =
|
|
261
|
+
const probeCollection = joinRemote(rootPath(this.config), ".pi-sync-probes", randomUUID());
|
|
254
262
|
const probe = `${probeCollection}/conditional.txt`;
|
|
255
263
|
const client = new WebDavClient(this.config, signal);
|
|
256
264
|
let created = false;
|
|
@@ -382,7 +390,7 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
382
390
|
}
|
|
383
391
|
|
|
384
392
|
export function rootPath(config: ResolvedWebDavBackend) {
|
|
385
|
-
return config.destination.path;
|
|
393
|
+
return config.destination.path === "./" ? "" : config.destination.path;
|
|
386
394
|
}
|
|
387
395
|
|
|
388
396
|
export function latestPath(config: ResolvedWebDavBackend) {
|
|
@@ -410,7 +418,7 @@ export function webDavBackendIdentity(config: ResolvedWebDavBackend) {
|
|
|
410
418
|
|
|
411
419
|
function webDavStorageLocation(config: ResolvedWebDavBackend) {
|
|
412
420
|
const url = new URL(secretFreeUrl(config.profile.url));
|
|
413
|
-
return `${url.host} · ${
|
|
421
|
+
return `${url.host} · ${config.destination.path}`;
|
|
414
422
|
}
|
|
415
423
|
|
|
416
424
|
function pointerFor(
|
|
@@ -586,7 +594,11 @@ function assertSafeDestination(config: ResolvedWebDavBackend) {
|
|
|
586
594
|
const url = new URL(config.profile.url);
|
|
587
595
|
if (url.username || url.password || url.search || url.hash)
|
|
588
596
|
throw new Error("Invalid WebDAV URL.");
|
|
589
|
-
for (const value of [
|
|
597
|
+
for (const [label, value] of [
|
|
598
|
+
["path", config.destination.path],
|
|
599
|
+
["namespace", config.destination.namespace],
|
|
600
|
+
]) {
|
|
601
|
+
if (label === "path" && value === "./") continue;
|
|
590
602
|
if (
|
|
591
603
|
!value ||
|
|
592
604
|
value.includes("\\") ||
|
|
@@ -41,6 +41,7 @@ export function normalizeWebDavUrl(value: string | undefined) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function normalizeWebDavPath(value: string | undefined) {
|
|
44
|
+
if (value?.trim() === "." || value?.trim() === "./") return "./";
|
|
44
45
|
const normalized = trimSlashes(normalizeOptionalString(value) ?? DEFAULT_PATH);
|
|
45
46
|
if (
|
|
46
47
|
!normalized ||
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, sessionTokenWarnings, syncSessionsWarnings } from "../settings/config.js";
|
|
3
|
+
import {
|
|
4
|
+
activeLocalConfigPath,
|
|
5
|
+
createLocalConfigDocument,
|
|
6
|
+
localConfigPath,
|
|
7
|
+
} from "../settings/config-file.js";
|
|
8
|
+
import { localConfigTemplate, readLocalConfigObject } from "../settings/settings-store.js";
|
|
9
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
10
|
+
import { unlock, withLock } from "../state/lock.js";
|
|
11
|
+
import {
|
|
12
|
+
migrateLegacyStateDirectory,
|
|
13
|
+
stateDirectoryMigrationNotice,
|
|
14
|
+
} from "../state/state-directory.js";
|
|
15
|
+
import { throwIfAborted } from "../sync/signals.js";
|
|
16
|
+
import { syncErrorGuidance } from "../sync/sync-error-guidance.js";
|
|
17
|
+
import { isSyncDecisionRequiredError, SetupPullRequiresUiError } from "../sync/sync-errors.js";
|
|
18
|
+
import type { SyncLoaders } from "../sync/sync-loaders.js";
|
|
19
|
+
import { RemoteSelectionMismatchError } from "../sync/sync-policy.js";
|
|
20
|
+
import { automaticSyncSummary } from "../ui/automatic-sync-summary.js";
|
|
21
|
+
import type { RunRouteResult } from "../ui/cancellable-operation.js";
|
|
22
|
+
import {
|
|
23
|
+
parseOptions,
|
|
24
|
+
resolveSyncCommand,
|
|
25
|
+
splitArgs,
|
|
26
|
+
usage,
|
|
27
|
+
validateCommandOptions,
|
|
28
|
+
} from "./command.js";
|
|
29
|
+
import type { CommandOptions } from "./command-types.js";
|
|
30
|
+
|
|
31
|
+
const STATUS_KEY = "sync";
|
|
32
|
+
|
|
33
|
+
export async function executeRecoveryCommand(
|
|
34
|
+
rawArgs: string,
|
|
35
|
+
ctx: ExtensionContext,
|
|
36
|
+
signal: AbortSignal | undefined,
|
|
37
|
+
loaders: SyncLoaders,
|
|
38
|
+
onCommit?: () => void,
|
|
39
|
+
setup?: string,
|
|
40
|
+
): Promise<RunRouteResult> {
|
|
41
|
+
try {
|
|
42
|
+
const [subcommand, ...rest] = splitArgs(rawArgs);
|
|
43
|
+
if (subcommand !== "sync" && subcommand !== "pull" && subcommand !== "push") {
|
|
44
|
+
throw new Error(`Unsupported sync recovery route: ${subcommand ?? "missing"}`);
|
|
45
|
+
}
|
|
46
|
+
const options = parseOptions(rest);
|
|
47
|
+
if (setup !== undefined) options.setup = setup;
|
|
48
|
+
if (signal) options.signal = signal;
|
|
49
|
+
if (onCommit) options.onCommit = onCommit;
|
|
50
|
+
options.reload = false;
|
|
51
|
+
options.auto = false;
|
|
52
|
+
validateCommandOptions(subcommand, options);
|
|
53
|
+
const operations = await loaders.operations();
|
|
54
|
+
throwIfAborted(options.signal);
|
|
55
|
+
if (subcommand === "push") {
|
|
56
|
+
const outcome = await withLock("push", () => operations.push(ctx, options));
|
|
57
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
58
|
+
}
|
|
59
|
+
if (subcommand === "pull") {
|
|
60
|
+
const outcome = await withLock("pull", () => operations.pull(ctx, options));
|
|
61
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
62
|
+
}
|
|
63
|
+
await withLock("sync", () => operations.syncBoth(ctx, options));
|
|
64
|
+
return { kind: "completed" };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (signal?.aborted) return { kind: "failed" };
|
|
67
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
68
|
+
if (error instanceof RemoteSelectionMismatchError) {
|
|
69
|
+
return { kind: "remote-selection-required", decision: error.decision };
|
|
70
|
+
}
|
|
71
|
+
if (isSyncDecisionRequiredError(error)) {
|
|
72
|
+
return { kind: "decision-required", decision: error.decision };
|
|
73
|
+
}
|
|
74
|
+
ctx.ui.notify(syncErrorGuidance(error), "error");
|
|
75
|
+
return { kind: "failed" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function executeCommand(
|
|
80
|
+
rawArgs: string,
|
|
81
|
+
ctx: ExtensionCommandContext,
|
|
82
|
+
signal: AbortSignal | undefined,
|
|
83
|
+
loaders: SyncLoaders,
|
|
84
|
+
onCommit?: () => void,
|
|
85
|
+
setup?: string,
|
|
86
|
+
): Promise<RunRouteResult> {
|
|
87
|
+
try {
|
|
88
|
+
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
89
|
+
if (signal?.aborted || !command) return { kind: "completed" };
|
|
90
|
+
const { subcommand, rest } = command;
|
|
91
|
+
const options = parseOptions(rest);
|
|
92
|
+
if (setup !== undefined) options.setup = setup;
|
|
93
|
+
if (signal) options.signal = signal;
|
|
94
|
+
if (onCommit) options.onCommit = onCommit;
|
|
95
|
+
validateCommandOptions(subcommand, options);
|
|
96
|
+
|
|
97
|
+
switch (subcommand) {
|
|
98
|
+
case "help":
|
|
99
|
+
ctx.ui.notify(usage(), "info");
|
|
100
|
+
return { kind: "completed" };
|
|
101
|
+
case "use": {
|
|
102
|
+
const { useSyncSetup } = await loaders.setupSwitch();
|
|
103
|
+
throwIfAborted(options.signal);
|
|
104
|
+
await useSyncSetup(
|
|
105
|
+
ctx,
|
|
106
|
+
options.args[0] ?? "",
|
|
107
|
+
async (selectedSetup) => {
|
|
108
|
+
const operations = await loaders.operations();
|
|
109
|
+
throwIfAborted(options.signal);
|
|
110
|
+
return withLock("pull", () =>
|
|
111
|
+
operations.pull(ctx, { ...options, setup: selectedSetup }),
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
undefined,
|
|
115
|
+
options.signal,
|
|
116
|
+
);
|
|
117
|
+
return { kind: "completed" };
|
|
118
|
+
}
|
|
119
|
+
case "init":
|
|
120
|
+
await initConfig(ctx, signal);
|
|
121
|
+
return { kind: "completed" };
|
|
122
|
+
case "config":
|
|
123
|
+
await showConfig(ctx, options);
|
|
124
|
+
return { kind: "completed" };
|
|
125
|
+
case "files": {
|
|
126
|
+
const { showFileSelection } = await import("../ui/file-selection.js");
|
|
127
|
+
throwIfAborted(options.signal);
|
|
128
|
+
await showFileSelection(ctx, options.setup, options.signal);
|
|
129
|
+
return { kind: "completed" };
|
|
130
|
+
}
|
|
131
|
+
case "status": {
|
|
132
|
+
const operations = await loaders.operations();
|
|
133
|
+
throwIfAborted(options.signal);
|
|
134
|
+
await operations.status(ctx, options);
|
|
135
|
+
return { kind: "completed" };
|
|
136
|
+
}
|
|
137
|
+
case "diff": {
|
|
138
|
+
const operations = await loaders.operations();
|
|
139
|
+
throwIfAborted(options.signal);
|
|
140
|
+
await operations.diff(ctx, options);
|
|
141
|
+
return { kind: "completed" };
|
|
142
|
+
}
|
|
143
|
+
case "doctor": {
|
|
144
|
+
const operations = await loaders.operations();
|
|
145
|
+
throwIfAborted(options.signal);
|
|
146
|
+
await operations.doctor(ctx, options);
|
|
147
|
+
return { kind: "completed" };
|
|
148
|
+
}
|
|
149
|
+
case "push": {
|
|
150
|
+
const operations = await loaders.operations();
|
|
151
|
+
throwIfAborted(options.signal);
|
|
152
|
+
const outcome = await withLock("push", () => operations.push(ctx, options));
|
|
153
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
154
|
+
}
|
|
155
|
+
case "pull": {
|
|
156
|
+
const operations = await loaders.operations();
|
|
157
|
+
throwIfAborted(options.signal);
|
|
158
|
+
const outcome = await withLock("pull", () => operations.pull(ctx, options));
|
|
159
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
160
|
+
}
|
|
161
|
+
case "sync": {
|
|
162
|
+
const operations = await loaders.operations();
|
|
163
|
+
throwIfAborted(options.signal);
|
|
164
|
+
await withLock("sync", () => operations.syncBoth(ctx, options));
|
|
165
|
+
return { kind: "completed" };
|
|
166
|
+
}
|
|
167
|
+
case "history": {
|
|
168
|
+
const operations = await loaders.operations();
|
|
169
|
+
throwIfAborted(options.signal);
|
|
170
|
+
await operations.history(ctx, options);
|
|
171
|
+
return { kind: "completed" };
|
|
172
|
+
}
|
|
173
|
+
case "rollback": {
|
|
174
|
+
const operations = await loaders.operations();
|
|
175
|
+
throwIfAborted(options.signal);
|
|
176
|
+
await withLock("rollback", () => operations.rollback(ctx, options));
|
|
177
|
+
return { kind: "completed" };
|
|
178
|
+
}
|
|
179
|
+
case "migrate-state":
|
|
180
|
+
await migrateStateDirectory(ctx, options);
|
|
181
|
+
return { kind: "completed" };
|
|
182
|
+
case "unlock":
|
|
183
|
+
await unlock(ctx, options);
|
|
184
|
+
return { kind: "completed" };
|
|
185
|
+
default:
|
|
186
|
+
ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
187
|
+
return { kind: "failed" };
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (signal?.aborted) return { kind: "failed" };
|
|
191
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
192
|
+
if (error instanceof SetupPullRequiresUiError) throw error;
|
|
193
|
+
if (error instanceof RemoteSelectionMismatchError) {
|
|
194
|
+
return { kind: "remote-selection-required", decision: error.decision };
|
|
195
|
+
}
|
|
196
|
+
if (isSyncDecisionRequiredError(error)) {
|
|
197
|
+
return { kind: "decision-required", decision: error.decision };
|
|
198
|
+
}
|
|
199
|
+
ctx.ui.notify(syncErrorGuidance(error), "error");
|
|
200
|
+
return { kind: "failed" };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function migrateStateDirectory(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
205
|
+
const notice = stateDirectoryMigrationNotice();
|
|
206
|
+
if (!notice) {
|
|
207
|
+
ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
!options.yes &&
|
|
212
|
+
!(await ctx.ui.confirm(
|
|
213
|
+
"Migrate pi-sync state directory",
|
|
214
|
+
"Confirm that every other Pi process is closed. pi-sync will atomically rename .pisync/ to pi-sync/ without merging or deleting either root.",
|
|
215
|
+
{ signal: options.signal },
|
|
216
|
+
))
|
|
217
|
+
) {
|
|
218
|
+
ctx.ui.notify("pi-sync state migration cancelled.", "info");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
throwIfAborted(options.signal);
|
|
222
|
+
const result = await migrateLegacyStateDirectory();
|
|
223
|
+
throwIfAborted(options.signal);
|
|
224
|
+
if (result.status === "ready") {
|
|
225
|
+
ctx.ui.notify("pi-sync already uses the canonical pi-sync/ state directory.", "info");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
232
|
+
const configPath = localConfigPath();
|
|
233
|
+
if (await readLocalConfigObject()) {
|
|
234
|
+
ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (ctx.mode === "tui") {
|
|
239
|
+
const { showSetupWizard } = await import("../ui/setup/setup-wizard.js");
|
|
240
|
+
throwIfAborted(signal);
|
|
241
|
+
await showSetupWizard(ctx, signal);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await createLocalConfigDocument(localConfigTemplate());
|
|
245
|
+
ctx.ui.notify(
|
|
246
|
+
`Created ${configPath}. Add a storage connection and sync setup before syncing.`,
|
|
247
|
+
"info",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
252
|
+
const config = await loadConfig(options.setup);
|
|
253
|
+
const warnings = [
|
|
254
|
+
...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
|
|
255
|
+
...syncSessionsWarnings(config),
|
|
256
|
+
];
|
|
257
|
+
const storageLines = configStorageLines(config);
|
|
258
|
+
ctx.ui.notify(
|
|
259
|
+
[
|
|
260
|
+
"pi-sync config:",
|
|
261
|
+
`sync setup: ${config.setupName}`,
|
|
262
|
+
`storage connection: ${config.connectionName}`,
|
|
263
|
+
...storageLines,
|
|
264
|
+
`storage path: ${config.storagePath}`,
|
|
265
|
+
`automatic sync: ${automaticSyncSummary(config.automatic)}`,
|
|
266
|
+
`included content: ${config.include.join(", ") || "none"}`,
|
|
267
|
+
`sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
|
|
268
|
+
`settings file: ${localConfigPath()}`,
|
|
269
|
+
...warnings,
|
|
270
|
+
].join("\n"),
|
|
271
|
+
warnings.length > 0 ? "warning" : "info",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function configStorageLines(config: AnySyncConfig) {
|
|
276
|
+
switch (config.backend.type) {
|
|
277
|
+
case "git":
|
|
278
|
+
return [
|
|
279
|
+
"kind: git",
|
|
280
|
+
`remote: ${displayGitRemote(config.backend.profile.remote)}`,
|
|
281
|
+
"authentication: existing Git/SSH credentials (not stored)",
|
|
282
|
+
`branch: ${config.backend.destination.branch}`,
|
|
283
|
+
];
|
|
284
|
+
case "webdav":
|
|
285
|
+
return [
|
|
286
|
+
"kind: webdav",
|
|
287
|
+
`url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
|
|
288
|
+
"username: configured (value hidden)",
|
|
289
|
+
"password: configured",
|
|
290
|
+
];
|
|
291
|
+
case "s3":
|
|
292
|
+
return [
|
|
293
|
+
"kind: s3",
|
|
294
|
+
`endpoint: ${config.backend.profile.endpoint}`,
|
|
295
|
+
`bucket: ${config.backend.destination.bucket}`,
|
|
296
|
+
`region: ${config.backend.profile.region}`,
|
|
297
|
+
"access key id: configured",
|
|
298
|
+
"secret access key: configured",
|
|
299
|
+
`session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function displayGitRemote(value: string | undefined) {
|
|
305
|
+
if (!value) return "missing";
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(value);
|
|
308
|
+
url.username = "";
|
|
309
|
+
url.password = "";
|
|
310
|
+
url.search = "";
|
|
311
|
+
url.hash = "";
|
|
312
|
+
return url.toString();
|
|
313
|
+
} catch {
|
|
314
|
+
return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function displayWebDavUrl(value: string | undefined, username: string | undefined) {
|
|
319
|
+
if (!value) return "missing";
|
|
320
|
+
try {
|
|
321
|
+
const url = new URL(value);
|
|
322
|
+
url.username = "";
|
|
323
|
+
url.password = "";
|
|
324
|
+
url.search = "";
|
|
325
|
+
url.hash = "";
|
|
326
|
+
return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
|
|
327
|
+
} catch {
|
|
328
|
+
return "invalid (value hidden)";
|
|
329
|
+
}
|
|
330
|
+
}
|