@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig } from "../settings/config.js";
|
|
3
|
+
import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
|
|
4
|
+
import { combineSignals } from "../sync/signals.js";
|
|
5
|
+
import { errorMessage } from "../sync/sync-errors.js";
|
|
6
|
+
import type { SyncLoaders } from "../sync/sync-loaders.js";
|
|
7
|
+
import { formatRemoteSelectionMismatch } from "../sync/sync-policy.js";
|
|
8
|
+
import type { RunRouteResult } from "../ui/cancellable-operation.js";
|
|
9
|
+
import {
|
|
10
|
+
observationMatchesConfig,
|
|
11
|
+
type SyncAttentionController,
|
|
12
|
+
type SyncAttentionOrigin,
|
|
13
|
+
syncAttentionMatchesConfig,
|
|
14
|
+
} from "../ui/sync-attention.js";
|
|
15
|
+
import { parseOptions, splitArgs } from "./command.js";
|
|
16
|
+
import { executeCommand, executeRecoveryCommand } from "./command-execution.js";
|
|
17
|
+
|
|
18
|
+
const STATUS_KEY = "sync";
|
|
19
|
+
|
|
20
|
+
export async function handleCommand(
|
|
21
|
+
rawArgs: string,
|
|
22
|
+
ctx: ExtensionCommandContext,
|
|
23
|
+
sessionSignal: AbortSignal,
|
|
24
|
+
loaders: SyncLoaders,
|
|
25
|
+
attention: SyncAttentionController,
|
|
26
|
+
) {
|
|
27
|
+
await reconcileObservation(attention, sessionSignal);
|
|
28
|
+
if (sessionSignal.aborted) return;
|
|
29
|
+
const run = (route: string, signal?: AbortSignal, onCommit?: () => void, target?: string) =>
|
|
30
|
+
executeCommand(
|
|
31
|
+
route,
|
|
32
|
+
ctx,
|
|
33
|
+
combineSignals(sessionSignal, signal),
|
|
34
|
+
loaders,
|
|
35
|
+
observationCommitCallback(attention, sessionSignal, onCommit),
|
|
36
|
+
target,
|
|
37
|
+
);
|
|
38
|
+
if (!rawArgs.trim()) {
|
|
39
|
+
try {
|
|
40
|
+
const { showSyncManager } = await import("../ui/manager-ui.js");
|
|
41
|
+
if (sessionSignal.aborted) return;
|
|
42
|
+
await showSyncManager(ctx, run, sessionSignal, {
|
|
43
|
+
getAttention: () => attention.current(),
|
|
44
|
+
getObservation: () => attention.observation(),
|
|
45
|
+
onObservationInvalidated: () => attention.clearObservation(),
|
|
46
|
+
onSelectionResolved: (expected) => {
|
|
47
|
+
if (attention.current() === expected) attention.clear(ctx);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (sessionSignal.aborted) return;
|
|
52
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
53
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
54
|
+
}
|
|
55
|
+
await reconcileObservation(attention, sessionSignal);
|
|
56
|
+
if (!sessionSignal.aborted) await attention.publish(ctx, sessionSignal);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const result = await run(rawArgs);
|
|
60
|
+
if (result.kind === "decision-required") {
|
|
61
|
+
ctx.ui.notify(result.decision.directMessage, "error");
|
|
62
|
+
} else if (result.kind === "remote-selection-required") {
|
|
63
|
+
const origin = directSelectionOrigin(rawArgs);
|
|
64
|
+
if (origin) attention.set(result.decision, origin);
|
|
65
|
+
const deterministic = splitArgs(rawArgs).some((arg) => arg === "--yes" || arg === "-y");
|
|
66
|
+
if (origin && ctx.mode === "tui" && !deterministic) {
|
|
67
|
+
await resolveSelectionAttention(ctx, attention, sessionSignal, loaders);
|
|
68
|
+
} else {
|
|
69
|
+
ctx.ui.notify(
|
|
70
|
+
formatRemoteSelectionMismatch(
|
|
71
|
+
result.decision.setupName,
|
|
72
|
+
result.decision.localInclude,
|
|
73
|
+
result.decision.remoteInclude,
|
|
74
|
+
),
|
|
75
|
+
"error",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await clearAttentionAfterCompletedOperation(rawArgs, result, ctx, attention, sessionSignal);
|
|
80
|
+
await reconcileSelectionAttention(ctx, attention, sessionSignal);
|
|
81
|
+
await reconcileObservation(attention, sessionSignal);
|
|
82
|
+
if (!sessionSignal.aborted) await attention.publish(ctx, sessionSignal);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function observationCommitCallback(
|
|
86
|
+
attention: SyncAttentionController,
|
|
87
|
+
sessionSignal: AbortSignal,
|
|
88
|
+
onCommit?: () => void,
|
|
89
|
+
) {
|
|
90
|
+
const observed = attention.observation();
|
|
91
|
+
return () => {
|
|
92
|
+
// Opening or cancelling a review changes nothing. A commit may change data
|
|
93
|
+
// even if later publication/baseline cleanup fails; never restore old hints.
|
|
94
|
+
if (!sessionSignal.aborted && attention.observation() === observed)
|
|
95
|
+
attention.clearObservation();
|
|
96
|
+
onCommit?.();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function reconcileObservation(attention: SyncAttentionController, signal: AbortSignal) {
|
|
101
|
+
const observed = attention.observation();
|
|
102
|
+
if (!observed || signal.aborted) return;
|
|
103
|
+
try {
|
|
104
|
+
const config = await loadConfig();
|
|
105
|
+
if (signal.aborted || attention.observation() !== observed) return;
|
|
106
|
+
if (!observationMatchesConfig(observed, config)) {
|
|
107
|
+
attention.clearObservation();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const state = await readStateForConfig(config);
|
|
111
|
+
if (signal.aborted || attention.observation() !== observed) return;
|
|
112
|
+
if (syncStateFingerprint(state) !== observed.inspection.stateIdentity)
|
|
113
|
+
attention.clearObservation();
|
|
114
|
+
} catch {
|
|
115
|
+
if (!signal.aborted && attention.observation() === observed) attention.clearObservation();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function clearAttentionAfterCompletedOperation(
|
|
120
|
+
rawArgs: string,
|
|
121
|
+
result: RunRouteResult,
|
|
122
|
+
ctx: ExtensionContext,
|
|
123
|
+
attention: SyncAttentionController,
|
|
124
|
+
signal: AbortSignal,
|
|
125
|
+
) {
|
|
126
|
+
if (result.kind !== "completed" || result.outcome === "cancelled" || signal.aborted) return;
|
|
127
|
+
const [command, ...rest] = splitArgs(rawArgs);
|
|
128
|
+
if (command !== "sync" && command !== "pull" && command !== "push") return;
|
|
129
|
+
const current = attention.current();
|
|
130
|
+
if (!current) return;
|
|
131
|
+
try {
|
|
132
|
+
const options = parseOptions(rest);
|
|
133
|
+
const setupName = options.setup ?? (await loadConfig()).setupName;
|
|
134
|
+
if (signal.aborted || attention.current() !== current) return;
|
|
135
|
+
if (current.decision.setupName === setupName) attention.clear(ctx);
|
|
136
|
+
} catch {
|
|
137
|
+
// Attention reconciliation below owns malformed or concurrently changed settings.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function reconcileSelectionAttention(
|
|
142
|
+
ctx: ExtensionContext,
|
|
143
|
+
attention: SyncAttentionController,
|
|
144
|
+
signal: AbortSignal,
|
|
145
|
+
) {
|
|
146
|
+
const current = attention.current();
|
|
147
|
+
if (!current || signal.aborted) return;
|
|
148
|
+
try {
|
|
149
|
+
const config = await loadConfig(current.decision.setupName);
|
|
150
|
+
if (signal.aborted || attention.current() !== current) return;
|
|
151
|
+
if (!syncAttentionMatchesConfig(current, config)) attention.clear(ctx);
|
|
152
|
+
} catch {
|
|
153
|
+
if (!signal.aborted && attention.current() === current) attention.clear(ctx);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function directSelectionOrigin(rawArgs: string): SyncAttentionOrigin | undefined {
|
|
158
|
+
const command = splitArgs(rawArgs)[0];
|
|
159
|
+
return command === "sync" || command === "pull" || command === "push" ? command : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function resolveSelectionAttention(
|
|
163
|
+
ctx: ExtensionContext,
|
|
164
|
+
attention: SyncAttentionController,
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
loaders: SyncLoaders,
|
|
167
|
+
options: {
|
|
168
|
+
cancelLabel?: string;
|
|
169
|
+
withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
|
|
170
|
+
} = {},
|
|
171
|
+
) {
|
|
172
|
+
const current = attention.current();
|
|
173
|
+
if (!current || signal.aborted) return;
|
|
174
|
+
const { dispatchManagerResult } = await import("../ui/manager-result-dispatcher.js");
|
|
175
|
+
if (signal.aborted || attention.current() !== current) return;
|
|
176
|
+
await dispatchManagerResult(
|
|
177
|
+
ctx,
|
|
178
|
+
{ kind: "remote-selection-required", decision: current.decision },
|
|
179
|
+
current.origin,
|
|
180
|
+
(route, actionSignal, onCommit, target) => {
|
|
181
|
+
const execute = () =>
|
|
182
|
+
executeRecoveryCommand(
|
|
183
|
+
route,
|
|
184
|
+
ctx,
|
|
185
|
+
combineSignals(signal, actionSignal),
|
|
186
|
+
loaders,
|
|
187
|
+
observationCommitCallback(attention, signal, onCommit),
|
|
188
|
+
target,
|
|
189
|
+
);
|
|
190
|
+
return options.withStateAccess ? options.withStateAccess(execute) : execute();
|
|
191
|
+
},
|
|
192
|
+
signal,
|
|
193
|
+
{
|
|
194
|
+
cancelLabel: options.cancelLabel,
|
|
195
|
+
withStateAccess: options.withStateAccess,
|
|
196
|
+
onSelectionResolved: () => {
|
|
197
|
+
if (attention.current() === current) attention.clear(ctx);
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CommandOptions {
|
|
2
|
+
yes: boolean;
|
|
3
|
+
force: boolean;
|
|
4
|
+
stale: boolean;
|
|
5
|
+
silent: boolean;
|
|
6
|
+
reload: boolean;
|
|
7
|
+
auto: boolean;
|
|
8
|
+
setup?: string;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
onCommit?: () => void;
|
|
11
|
+
args: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CommandArgumentCompletion {
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { CommandArgumentCompletion, CommandOptions } from "./types.js";
|
|
2
|
+
import type { CommandArgumentCompletion, CommandOptions } from "./command-types.js";
|
|
3
3
|
|
|
4
4
|
const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
5
5
|
{ value: "--yes", label: "--yes", description: "Skip confirmation prompts" },
|
|
@@ -235,6 +235,7 @@ export function usage() {
|
|
|
235
235
|
return [
|
|
236
236
|
"Usage: /sync <command>",
|
|
237
237
|
`Commands: ${commands}`,
|
|
238
|
+
"Automatic sync: background startup checks only; use /sync to review and transfer. At shutdown, selected content is pushed automatically only when sessions are included. Startup checks run in TUI/RPC, not print/JSON.",
|
|
238
239
|
"Settings: use /sync init or edit storage connections and sync setups in ~/.pi/agent/pi-sync.json (or the configured Pi agent directory). Version 1 and version 2 settings are unsupported and are never rewritten.",
|
|
239
240
|
].join("\n");
|
|
240
241
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { backendIdentityCoordinates } from "../backends/backend-identity.js";
|
|
3
|
+
import {
|
|
4
|
+
normalizeGitBranch,
|
|
5
|
+
normalizeGitDirectory,
|
|
6
|
+
normalizeGitRemote,
|
|
7
|
+
} from "../backends/git/git-config.js";
|
|
8
|
+
import { normalizeWebDavPath, normalizeWebDavUrl } from "../backends/webdav/webdav-config.js";
|
|
9
|
+
import { normalizeSyncInclude } from "../sync/sync-policy.js";
|
|
10
|
+
import { localConfigPath, readActiveLocalConfigDocumentForRepair } from "./config-file.js";
|
|
11
|
+
import { requireSettings } from "./settings-store.js";
|
|
12
|
+
import type {
|
|
13
|
+
AnySyncConfig,
|
|
14
|
+
OnSwitchAction,
|
|
15
|
+
PartialConfig,
|
|
16
|
+
PiSyncSettingsV3,
|
|
17
|
+
StorageConnectionSettings,
|
|
18
|
+
SyncSetupSettings,
|
|
19
|
+
} from "./settings-types.js";
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_ON_SWITCH,
|
|
22
|
+
isCloudflareR2Endpoint,
|
|
23
|
+
normalizeS3Bucket,
|
|
24
|
+
normalizeS3Endpoint,
|
|
25
|
+
normalizeStoragePath,
|
|
26
|
+
optionalString,
|
|
27
|
+
ownObject,
|
|
28
|
+
requiredString,
|
|
29
|
+
validateConfigName,
|
|
30
|
+
validateSettingsDocument,
|
|
31
|
+
} from "./settings-validation.js";
|
|
32
|
+
|
|
33
|
+
export async function loadConfig(setupName?: string): Promise<AnySyncConfig> {
|
|
34
|
+
return configFromSettings(await requireSettings(), setupName);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A background freshness read must never trigger legacy-file publication. */
|
|
38
|
+
export async function loadConfigForCheck(): Promise<AnySyncConfig> {
|
|
39
|
+
const document = await readActiveLocalConfigDocumentForRepair();
|
|
40
|
+
if (!document) throw new Error(`Missing pi-sync settings: ${localConfigPath()}`);
|
|
41
|
+
validateSettingsDocument(document.parsed);
|
|
42
|
+
return configFromSettings(document.parsed as PiSyncSettingsV3);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function configFromSettings(settings: PiSyncSettingsV3, setupName?: string): AnySyncConfig {
|
|
46
|
+
const selectedName = setupName ?? settings.activeSyncSetup;
|
|
47
|
+
if (!selectedName) throw new Error("No sync setups are configured.");
|
|
48
|
+
validateConfigName(selectedName, "sync setup");
|
|
49
|
+
const setup = ownObject<SyncSetupSettings>(settings.syncSetups, selectedName);
|
|
50
|
+
if (!setup)
|
|
51
|
+
throw new Error(`Invalid pi-sync settings: sync setup “${selectedName}” was not found.`);
|
|
52
|
+
const connectionName = setup.storage.connection;
|
|
53
|
+
const connection = ownObject<StorageConnectionSettings>(
|
|
54
|
+
settings.storageConnections,
|
|
55
|
+
connectionName,
|
|
56
|
+
);
|
|
57
|
+
if (!connection) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid pi-sync settings: sync setup “${selectedName}” references missing storage connection “${connectionName}”.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return resolveSyncConfig(
|
|
63
|
+
selectedName,
|
|
64
|
+
setup,
|
|
65
|
+
connectionName,
|
|
66
|
+
connection,
|
|
67
|
+
settings.onSwitch,
|
|
68
|
+
settings.skipSecretScan ?? false,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type SyncSetupStorageReview = Pick<
|
|
73
|
+
PartialConfig,
|
|
74
|
+
"connectionName" | "storageKind" | "storagePath" | "bucket" | "branch"
|
|
75
|
+
>;
|
|
76
|
+
|
|
77
|
+
/** A validated setup-facing projection used by manager and settings UI. */
|
|
78
|
+
export async function loadPartialConfig(setupName?: string): Promise<PartialConfig> {
|
|
79
|
+
const config = await loadConfig(setupName);
|
|
80
|
+
return {
|
|
81
|
+
setupName: config.setupName,
|
|
82
|
+
...storageReviewFromConfig(config),
|
|
83
|
+
include: [...config.include],
|
|
84
|
+
automatic: config.automatic,
|
|
85
|
+
onSwitch: config.onSwitch,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function syncSetupStorageReview(
|
|
90
|
+
setupName: string,
|
|
91
|
+
setup: SyncSetupSettings,
|
|
92
|
+
connectionName: string,
|
|
93
|
+
connection: StorageConnectionSettings,
|
|
94
|
+
): SyncSetupStorageReview {
|
|
95
|
+
return storageReviewFromConfig(
|
|
96
|
+
resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH, false),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function syncSetupReviewIdentity(
|
|
101
|
+
setupName: string,
|
|
102
|
+
setup: SyncSetupSettings,
|
|
103
|
+
connectionName: string,
|
|
104
|
+
connection: StorageConnectionSettings,
|
|
105
|
+
) {
|
|
106
|
+
return syncConfigReviewIdentity(
|
|
107
|
+
resolveSyncConfig(setupName, setup, connectionName, connection, DEFAULT_ON_SWITCH, false),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function syncConfigReviewIdentity(config: AnySyncConfig) {
|
|
112
|
+
return JSON.stringify([
|
|
113
|
+
config.setupName,
|
|
114
|
+
config.connectionName,
|
|
115
|
+
backendIdentityCoordinates(config),
|
|
116
|
+
config.include,
|
|
117
|
+
config.automatic,
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function syncConfigReviewFingerprint(config: AnySyncConfig) {
|
|
122
|
+
return createHash("sha256").update(syncConfigReviewIdentity(config)).digest("hex");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Internal freshness token, including credentials; never display or persist it. */
|
|
126
|
+
export function syncCheckConfigFingerprint(config: AnySyncConfig) {
|
|
127
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
|
|
131
|
+
return {
|
|
132
|
+
connectionName: config.connectionName,
|
|
133
|
+
storageKind: config.backend.type,
|
|
134
|
+
storagePath: config.storagePath,
|
|
135
|
+
...(config.backend.type === "s3"
|
|
136
|
+
? { bucket: config.backend.destination.bucket }
|
|
137
|
+
: config.backend.type === "git"
|
|
138
|
+
? { branch: config.backend.destination.branch }
|
|
139
|
+
: {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveSyncConfig(
|
|
144
|
+
setupName: string,
|
|
145
|
+
setup: SyncSetupSettings,
|
|
146
|
+
connectionName: string,
|
|
147
|
+
connection: StorageConnectionSettings,
|
|
148
|
+
onSwitch: OnSwitchAction,
|
|
149
|
+
skipSecretScan: boolean,
|
|
150
|
+
): AnySyncConfig {
|
|
151
|
+
const storagePath = normalizeStoragePath(setup.storage.path);
|
|
152
|
+
const namespace =
|
|
153
|
+
storagePath === "./" ? "root" : storagePath.slice(storagePath.lastIndexOf("/") + 1);
|
|
154
|
+
const include = normalizeSyncInclude(setup.sync.include);
|
|
155
|
+
const common = {
|
|
156
|
+
setupName,
|
|
157
|
+
connectionName,
|
|
158
|
+
storagePath,
|
|
159
|
+
snapshotIdentity: namespace,
|
|
160
|
+
include,
|
|
161
|
+
automatic: setup.sync.automatic,
|
|
162
|
+
onSwitch,
|
|
163
|
+
skipSecretScan,
|
|
164
|
+
};
|
|
165
|
+
if (connection.type === "git") {
|
|
166
|
+
return {
|
|
167
|
+
...common,
|
|
168
|
+
backend: {
|
|
169
|
+
type: "git",
|
|
170
|
+
profile: { kind: "git", remote: normalizeGitRemote(connection.remote) as string },
|
|
171
|
+
destination: {
|
|
172
|
+
branch: normalizeGitBranch(setup.storage.branch),
|
|
173
|
+
directory: normalizeGitDirectory(storagePath),
|
|
174
|
+
namespace,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (connection.type === "webdav") {
|
|
180
|
+
return {
|
|
181
|
+
...common,
|
|
182
|
+
backend: {
|
|
183
|
+
type: "webdav",
|
|
184
|
+
profile: {
|
|
185
|
+
kind: "webdav",
|
|
186
|
+
url: normalizeWebDavUrl(connection.url) as string,
|
|
187
|
+
username: connection.credentials.username,
|
|
188
|
+
password: connection.credentials.password,
|
|
189
|
+
},
|
|
190
|
+
destination: { path: normalizeWebDavPath(storagePath), namespace },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
...common,
|
|
196
|
+
backend: {
|
|
197
|
+
type: "s3",
|
|
198
|
+
profile: {
|
|
199
|
+
kind: isCloudflareR2Endpoint(connection.endpoint) ? "r2" : "s3-compatible",
|
|
200
|
+
endpoint: normalizeS3Endpoint(connection.endpoint),
|
|
201
|
+
region: requiredString(connection.region, "S3 region"),
|
|
202
|
+
accessKeyId: connection.credentials.accessKeyId,
|
|
203
|
+
secretAccessKey: connection.credentials.secretAccessKey,
|
|
204
|
+
sessionToken: optionalString(connection.credentials.sessionToken, "S3 session token"),
|
|
205
|
+
},
|
|
206
|
+
destination: {
|
|
207
|
+
bucket: normalizeS3Bucket(setup.storage.bucket),
|
|
208
|
+
prefix: storagePath,
|
|
209
|
+
namespace,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
|
|
216
|
+
if (!isCloudflareR2Endpoint(config.endpoint) || !config.sessionToken) return [];
|
|
217
|
+
return [
|
|
218
|
+
"session token: configured for Cloudflare R2; if R2 rejects X-Amz-Security-Token, pi-sync retries once without it. R2 static access keys usually do not need a session token.",
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function syncSessionsWarnings(config: { include: readonly string[] }) {
|
|
223
|
+
if (!config.include.includes("sessions")) return [];
|
|
224
|
+
return [
|
|
225
|
+
"sessions: included; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
|
|
230
|
+
if (value === undefined) return defaultValue;
|
|
231
|
+
if (typeof value === "boolean") return value;
|
|
232
|
+
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function isExplicitlyEnabled(value: boolean | string | undefined) {
|
|
236
|
+
if (typeof value === "boolean") return value;
|
|
237
|
+
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
|
238
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { normalizeSyncInclude } from "../sync/sync-policy.js";
|
|
3
|
+
import { type SyncSetupStorageReview, syncSetupStorageReview } from "./config.js";
|
|
4
|
+
import { localConfigPath } from "./config-file.js";
|
|
5
|
+
import { updateLocalConfig } from "./settings-store.js";
|
|
6
|
+
import type {
|
|
7
|
+
PiSyncSettingsV3,
|
|
8
|
+
StorageConnectionSettings,
|
|
9
|
+
SyncSetupSettings,
|
|
10
|
+
} from "./settings-types.js";
|
|
11
|
+
import { effectiveSyncSetupRemoteIdentity, validateConfigName } from "./settings-validation.js";
|
|
11
12
|
|
|
12
13
|
export class SyncSetupReviewChangedError extends Error {
|
|
13
14
|
constructor(message: string) {
|
|
@@ -40,13 +41,12 @@ export async function saveNewV3Settings(
|
|
|
40
41
|
},
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
|
-
|
|
44
|
+
return updateLocalConfig((current) => {
|
|
44
45
|
if (Object.keys(current.storageConnections).length || Object.keys(current.syncSetups).length) {
|
|
45
46
|
throw new Error(`Settings already exist: ${localConfigPath()}`);
|
|
46
47
|
}
|
|
47
|
-
return settings;
|
|
48
|
+
return { ...current, ...settings };
|
|
48
49
|
}, signal);
|
|
49
|
-
return settings;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export async function addStorageConnection(
|
|
@@ -92,7 +92,12 @@ export async function updateStorageConnection(
|
|
|
92
92
|
}, signal);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export async function addSyncSetup(
|
|
95
|
+
export async function addSyncSetup(
|
|
96
|
+
name: string,
|
|
97
|
+
setup: SyncSetupSettings,
|
|
98
|
+
signal?: AbortSignal,
|
|
99
|
+
expectedConnection?: StorageConnectionSettings,
|
|
100
|
+
) {
|
|
96
101
|
validateConfigName(name, "sync setup");
|
|
97
102
|
await updateSettings((settings) => {
|
|
98
103
|
if (Object.hasOwn(settings.syncSetups, name))
|
|
@@ -100,6 +105,14 @@ export async function addSyncSetup(name: string, setup: SyncSetupSettings, signa
|
|
|
100
105
|
if (!Object.hasOwn(settings.storageConnections, setup.storage.connection)) {
|
|
101
106
|
throw new Error(`Storage connection not found: ${setup.storage.connection}`);
|
|
102
107
|
}
|
|
108
|
+
if (
|
|
109
|
+
expectedConnection &&
|
|
110
|
+
!isDeepStrictEqual(settings.storageConnections[setup.storage.connection], expectedConnection)
|
|
111
|
+
) {
|
|
112
|
+
throw new SyncSetupReviewChangedError(
|
|
113
|
+
"Storage connection changed while the setup review was open; reopen it.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
103
116
|
const nextSetups = { ...settings.syncSetups, [name]: structuredClone(setup) };
|
|
104
117
|
assertUniqueLocations(nextSetups, settings.storageConnections);
|
|
105
118
|
return {
|
|
@@ -116,6 +129,7 @@ export async function updateSyncSetup(
|
|
|
116
129
|
options: {
|
|
117
130
|
expectedStorage?: SyncSetupStorageReview;
|
|
118
131
|
expectedInclude?: readonly string[];
|
|
132
|
+
expectedConnection?: StorageConnectionSettings;
|
|
119
133
|
signal?: AbortSignal;
|
|
120
134
|
} = {},
|
|
121
135
|
) {
|
|
@@ -146,6 +160,17 @@ export async function updateSyncSetup(
|
|
|
146
160
|
);
|
|
147
161
|
}
|
|
148
162
|
}
|
|
163
|
+
if (
|
|
164
|
+
options.expectedConnection &&
|
|
165
|
+
!isDeepStrictEqual(
|
|
166
|
+
settings.storageConnections[setup.storage.connection],
|
|
167
|
+
options.expectedConnection,
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
throw new SyncSetupReviewChangedError(
|
|
171
|
+
"Storage connection changed while the setup review was open; reopen it.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
149
174
|
const nextSetup = update(structuredClone(setup));
|
|
150
175
|
if (!Object.hasOwn(settings.storageConnections, nextSetup.storage.connection)) {
|
|
151
176
|
throw new Error(`Storage connection not found: ${nextSetup.storage.connection}`);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
localConfigPath,
|
|
6
|
+
readMigratingLocalConfigDocument,
|
|
7
|
+
updateLocalConfigDocument,
|
|
8
|
+
} from "./config-file.js";
|
|
9
|
+
import type { OnSwitchAction, PiSyncSettingsV3 } from "./settings-types.js";
|
|
10
|
+
import { DEFAULT_ON_SWITCH, validateSettingsDocument } from "./settings-validation.js";
|
|
11
|
+
|
|
12
|
+
let configUpdateQueue: Promise<void> = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
export async function requireSettings() {
|
|
15
|
+
const settings = await readLocalConfigObject();
|
|
16
|
+
if (!settings) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Missing pi-sync settings. Open /sync and choose Set up sync, or create ${localConfigPath()}.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return settings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function configuredSyncSetupNames() {
|
|
25
|
+
const settings = await readLocalConfigObject();
|
|
26
|
+
return settings
|
|
27
|
+
? Object.keys(settings.syncSetups).sort((left, right) => left.localeCompare(right))
|
|
28
|
+
: [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadOnSwitch(): Promise<OnSwitchAction> {
|
|
32
|
+
return (await requireSettings()).onSwitch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function localConfigTemplate(): PiSyncSettingsV3 {
|
|
36
|
+
return {
|
|
37
|
+
version: 3,
|
|
38
|
+
onSwitch: DEFAULT_ON_SWITCH,
|
|
39
|
+
skipSecretScan: false,
|
|
40
|
+
storageConnections: {},
|
|
41
|
+
syncSetups: {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readLocalConfigDocument() {
|
|
46
|
+
const document = await readMigratingLocalConfigDocument((settings) => {
|
|
47
|
+
validateSettingsDocument(settings);
|
|
48
|
+
});
|
|
49
|
+
if (document) validateSettingsDocument(document.parsed);
|
|
50
|
+
return document;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readLocalConfigObject(): Promise<PiSyncSettingsV3 | undefined> {
|
|
54
|
+
return (await readLocalConfigDocument())?.parsed as PiSyncSettingsV3 | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function updateLocalConfig(
|
|
58
|
+
update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
) {
|
|
61
|
+
const operation = configUpdateQueue.then(() => {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
return performLocalConfigUpdate(update, signal);
|
|
64
|
+
});
|
|
65
|
+
configUpdateQueue = operation.then(
|
|
66
|
+
() => undefined,
|
|
67
|
+
() => undefined,
|
|
68
|
+
);
|
|
69
|
+
return operation;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function performLocalConfigUpdate(
|
|
73
|
+
update: (current: PiSyncSettingsV3) => PiSyncSettingsV3,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
) {
|
|
76
|
+
return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
|
|
80
|
+
validateSettingsDocument(value as Record<string, unknown>);
|
|
81
|
+
const configPath = localConfigPath();
|
|
82
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
83
|
+
try {
|
|
84
|
+
const stat = await fs.lstat(configPath);
|
|
85
|
+
if (stat.isSymbolicLink())
|
|
86
|
+
throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
|
|
87
|
+
if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
90
|
+
}
|
|
91
|
+
const temporaryPath = path.join(
|
|
92
|
+
path.dirname(configPath),
|
|
93
|
+
`.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
94
|
+
);
|
|
95
|
+
let handle: fs.FileHandle | undefined;
|
|
96
|
+
try {
|
|
97
|
+
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
98
|
+
await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
99
|
+
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
100
|
+
await handle.sync();
|
|
101
|
+
await handle.close();
|
|
102
|
+
handle = undefined;
|
|
103
|
+
await fs.rename(temporaryPath, configPath);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
await handle?.close().catch(() => undefined);
|
|
106
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|