@narumitw/pi-sync 0.50.3 → 0.51.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 +4 -1
- package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
- package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
- package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
- package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
- package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
- package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
- package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
- package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
- package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
- package/dist/chunks/chunk-6546TS54.ts.map +7 -0
- package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
- package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
- package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
- package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
- package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
- package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
- package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
- package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
- package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
- package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
- package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
- package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
- package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
- package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
- package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
- package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
- package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
- package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
- package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
- package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
- package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
- package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
- package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
- package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
- package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
- package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
- package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
- package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
- package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
- package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
- package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
- package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
- package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
- package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
- package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
- package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
- package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
- package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
- package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
- package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
- package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
- package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
- package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
- package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
- package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
- package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
- package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
- package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
- package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
- package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
- package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
- package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
- package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
- package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
- package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
- package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
- package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
- package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
- package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
- package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
- package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
- package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
- package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
- package/dist/index.ts +49 -74
- package/dist/index.ts.map +3 -3
- package/docs/settings.md +9 -0
- package/package.json +61 -62
- package/src/backends/backend-factory.ts +15 -15
- package/src/backends/backend-identity.ts +31 -31
- package/src/backends/backend-types.ts +42 -42
- package/src/backends/git/git-backend.ts +714 -788
- package/src/backends/git/git-config.ts +104 -111
- package/src/backends/git/git-runner.ts +230 -247
- package/src/backends/git/git-storage.ts +228 -245
- package/src/backends/s3/s3-backend.ts +408 -441
- package/src/backends/s3/s3-client.ts +288 -333
- package/src/backends/sync-backend.ts +65 -65
- package/src/backends/webdav/webdav-backend.ts +542 -589
- package/src/backends/webdav/webdav-client.ts +292 -316
- package/src/backends/webdav/webdav-config.ts +57 -68
- package/src/commands/command-execution.ts +270 -289
- package/src/commands/command-handler.ts +160 -163
- package/src/commands/command-types.ts +13 -13
- package/src/commands/command.ts +190 -196
- package/src/commands/setup-completions.ts +1 -1
- package/src/paths.ts +47 -47
- package/src/settings/config-errors.ts +4 -5
- package/src/settings/config-file.ts +473 -506
- package/src/settings/config.ts +171 -174
- package/src/settings/settings-management.ts +191 -216
- package/src/settings/settings-store.ts +63 -77
- package/src/settings/settings-types.ts +78 -75
- package/src/settings/settings-validation.ts +328 -364
- package/src/snapshot/session-paths.ts +41 -55
- package/src/snapshot/snapshot-apply.ts +197 -229
- package/src/snapshot/snapshot-codec.ts +29 -31
- package/src/snapshot/snapshot-paths.ts +4 -6
- package/src/snapshot/snapshot-transaction.ts +161 -170
- package/src/snapshot/snapshot-types.ts +24 -24
- package/src/snapshot/snapshot.ts +310 -343
- package/src/state/json-file.ts +11 -11
- package/src/state/lock.ts +226 -242
- package/src/state/lockfile-fs.ts +20 -20
- package/src/state/operation-availability.ts +36 -41
- package/src/state/state-directory.ts +171 -173
- package/src/state/state-types.ts +15 -15
- package/src/state/sync-state-store.ts +23 -23
- package/src/sync/automatic-sync.ts +75 -80
- package/src/sync/remote-snapshot.ts +52 -60
- package/src/sync/setup-switch.ts +99 -114
- package/src/sync/signals.ts +3 -5
- package/src/sync/startup-check.ts +97 -111
- package/src/sync/sync-decision.ts +76 -80
- package/src/sync/sync-error-guidance.ts +41 -48
- package/src/sync/sync-errors.ts +10 -10
- package/src/sync/sync-inspection.ts +29 -33
- package/src/sync/sync-loaders.ts +28 -36
- package/src/sync/sync-mutations.ts +510 -622
- package/src/sync/sync-operations.ts +7 -7
- package/src/sync/sync-policy.ts +296 -326
- package/src/sync/sync-queries.ts +175 -194
- package/src/sync/sync-state.ts +74 -100
- package/src/sync-extension.ts +89 -101
- package/src/sync.ts +26 -26
- package/src/types.ts +37 -37
- package/src/ui/automatic-sync-summary.ts +1 -3
- package/src/ui/cancellable-operation.ts +89 -101
- package/src/ui/file-selection.ts +250 -274
- package/src/ui/manager-attention.ts +51 -63
- package/src/ui/manager-recovery.ts +48 -57
- package/src/ui/manager-result-dispatcher.ts +62 -76
- package/src/ui/manager-state.ts +200 -232
- package/src/ui/manager-ui.ts +264 -362
- package/src/ui/remote-selection-ui.ts +547 -588
- package/src/ui/secret-input.ts +197 -205
- package/src/ui/settings-ui.ts +175 -172
- package/src/ui/setup/git-ui.ts +232 -261
- package/src/ui/setup/s3-credentials-ui.ts +58 -65
- package/src/ui/setup/s3-ui.ts +171 -195
- package/src/ui/setup/setup-actions.ts +91 -101
- package/src/ui/setup/setup-location-ui.ts +121 -137
- package/src/ui/setup/setup-name-ui.ts +27 -27
- package/src/ui/setup/setup-prompts.ts +65 -65
- package/src/ui/setup/setup-review.ts +58 -59
- package/src/ui/setup/setup-switcher.ts +156 -160
- package/src/ui/setup/setup-wizard.ts +28 -28
- package/src/ui/setup/text-input.ts +54 -58
- package/src/ui/setup/webdav-ui.ts +246 -293
- package/src/ui/storage-connections-ui.ts +317 -369
- package/src/ui/sync-attention.ts +148 -164
- package/src/ui/sync-format.ts +110 -121
- package/src/ui/sync-resolution-ui.ts +147 -153
- package/src/ui/sync-setups-ui.ts +218 -238
- package/src/ui/sync-status.ts +17 -0
- package/src/ui/terminal-text.ts +2 -2
- package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
- package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
- package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
- package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
- package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
- package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
- package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
- package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
- package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
- package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
- package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
- package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
- package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
- package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
- package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
- package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
- package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
- package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
- package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
- package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
- package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
- package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
- package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
- package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
- package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
- package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
- package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
- package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
- package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
- package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
- /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
- /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
- /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
mkdir,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
realpath,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmdir,
|
|
8
|
+
rmdirSync,
|
|
9
|
+
stat,
|
|
10
|
+
statSync,
|
|
11
|
+
utimes,
|
|
12
|
+
utimesSync,
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import fs from "node:fs/promises";
|
|
15
15
|
import path from "node:path";
|
|
@@ -23,16 +23,16 @@ const legacyPresenceNoticed = new Set<string>();
|
|
|
23
23
|
const CONFIG_LOCK_STALE_MS = 30_000;
|
|
24
24
|
const CONFIG_LOCK_UPDATE_MS = 10_000;
|
|
25
25
|
const LOCKFILE_FS_ADAPTER = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
mkdir,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
realpath,
|
|
29
|
+
realpathSync,
|
|
30
|
+
rmdir,
|
|
31
|
+
rmdirSync,
|
|
32
|
+
stat,
|
|
33
|
+
statSync,
|
|
34
|
+
utimes,
|
|
35
|
+
utimesSync,
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
type PublishFile = (source: string, destination: string) => Promise<void>;
|
|
@@ -44,596 +44,563 @@ let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
|
|
|
44
44
|
|
|
45
45
|
export type FileIdentity = { dev: number; ino: number };
|
|
46
46
|
|
|
47
|
-
export async function withConfigFilePublicationForTest<T>(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} finally {
|
|
56
|
-
publishConfigFile = previous;
|
|
57
|
-
}
|
|
47
|
+
export async function withConfigFilePublicationForTest<T>(publish: PublishFile, run: () => Promise<T>): Promise<T> {
|
|
48
|
+
const previous = publishConfigFile;
|
|
49
|
+
publishConfigFile = publish;
|
|
50
|
+
try {
|
|
51
|
+
return await run();
|
|
52
|
+
} finally {
|
|
53
|
+
publishConfigFile = previous;
|
|
54
|
+
}
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
export async function withConfigPublicationReadyHookForTest<T>(
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
hook: () => Promise<void>,
|
|
59
|
+
run: () => Promise<T>,
|
|
63
60
|
): Promise<T> {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
const previous = beforeConfigPublicationHook;
|
|
62
|
+
beforeConfigPublicationHook = hook;
|
|
63
|
+
try {
|
|
64
|
+
return await run();
|
|
65
|
+
} finally {
|
|
66
|
+
beforeConfigPublicationHook = previous;
|
|
67
|
+
}
|
|
71
68
|
}
|
|
72
69
|
|
|
73
70
|
export async function withMissingConfigReadProbeHookForTest<T>(
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
hook: () => Promise<void>,
|
|
72
|
+
run: () => Promise<T>,
|
|
76
73
|
): Promise<T> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
const previous = afterMissingConfigReadProbeHook;
|
|
75
|
+
afterMissingConfigReadProbeHook = hook;
|
|
76
|
+
try {
|
|
77
|
+
return await run();
|
|
78
|
+
} finally {
|
|
79
|
+
afterMissingConfigReadProbeHook = previous;
|
|
80
|
+
}
|
|
84
81
|
}
|
|
85
82
|
|
|
86
83
|
export async function withConfigReplacementInstalledHookForTest<T>(
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
hook: () => Promise<void>,
|
|
85
|
+
run: () => Promise<T>,
|
|
89
86
|
): Promise<T> {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
const previous = afterReplacementInstalledHook;
|
|
88
|
+
afterReplacementInstalledHook = hook;
|
|
89
|
+
try {
|
|
90
|
+
return await run();
|
|
91
|
+
} finally {
|
|
92
|
+
afterReplacementInstalledHook = previous;
|
|
93
|
+
}
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
export async function withConfigQuarantinedHookForTest<T>(
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
hook: () => Promise<void>,
|
|
98
|
+
run: () => Promise<T>,
|
|
102
99
|
): Promise<T> {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
const previous = afterConfigQuarantinedHook;
|
|
101
|
+
afterConfigQuarantinedHook = hook;
|
|
102
|
+
try {
|
|
103
|
+
return await run();
|
|
104
|
+
} finally {
|
|
105
|
+
afterConfigQuarantinedHook = previous;
|
|
106
|
+
}
|
|
110
107
|
}
|
|
111
108
|
|
|
112
109
|
type ConfigSnapshot = {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
bytes: Buffer;
|
|
111
|
+
identity: FileIdentity;
|
|
112
|
+
parsed: Record<string, unknown>;
|
|
116
113
|
};
|
|
117
114
|
|
|
118
115
|
export type LocalConfigDocument = ConfigSnapshot & { path: string };
|
|
119
116
|
|
|
120
117
|
export function localConfigPath() {
|
|
121
|
-
|
|
118
|
+
return path.join(getAgentDir(), CONFIG_FILE_NAME);
|
|
122
119
|
}
|
|
123
120
|
|
|
124
121
|
export function legacyLocalConfigPath() {
|
|
125
|
-
|
|
122
|
+
return path.join(getAgentDir(), LEGACY_CONFIG_FILE_NAME);
|
|
126
123
|
}
|
|
127
124
|
|
|
128
125
|
export async function activeLocalConfigPath() {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
126
|
+
const canonicalPath = localConfigPath();
|
|
127
|
+
const legacyPath = legacyLocalConfigPath();
|
|
128
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
129
|
+
if (await pathExists(canonicalPath)) return canonicalPath;
|
|
130
|
+
return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
|
|
131
|
+
});
|
|
135
132
|
}
|
|
136
133
|
|
|
137
134
|
export function consumeLocalConfigMigrationNotice() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
const configPath = localConfigPath();
|
|
136
|
+
const notice = configMigrationNotices.get(configPath);
|
|
137
|
+
configMigrationNotices.delete(configPath);
|
|
138
|
+
return notice;
|
|
142
139
|
}
|
|
143
140
|
|
|
144
|
-
export async function readActiveLocalConfigDocumentForRepair(): Promise<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return snapshot ? { path: filePath, ...snapshot } : undefined;
|
|
153
|
-
});
|
|
141
|
+
export async function readActiveLocalConfigDocumentForRepair(): Promise<LocalConfigDocument | undefined> {
|
|
142
|
+
const canonicalPath = localConfigPath();
|
|
143
|
+
const legacyPath = legacyLocalConfigPath();
|
|
144
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
145
|
+
const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
|
|
146
|
+
const snapshot = await readConfigSnapshotIfExists(filePath);
|
|
147
|
+
return snapshot ? { path: filePath, ...snapshot } : undefined;
|
|
148
|
+
});
|
|
154
149
|
}
|
|
155
150
|
|
|
156
151
|
export async function readMigratingLocalConfigDocument(
|
|
157
|
-
|
|
152
|
+
validateForMigration: (settings: Record<string, unknown>) => void,
|
|
158
153
|
): Promise<LocalConfigDocument | undefined> {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
154
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
155
|
+
const configPath = await prepareLocalConfigPath(validateForMigration);
|
|
156
|
+
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
157
|
+
return snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
158
|
+
});
|
|
164
159
|
}
|
|
165
160
|
|
|
166
161
|
async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promise<T> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
162
|
+
if ((await pathExists(localConfigPath())) || (await pathExists(legacyLocalConfigPath()))) {
|
|
163
|
+
return withLocalConfigFileLock(read);
|
|
164
|
+
}
|
|
165
|
+
await afterMissingConfigReadProbeHook();
|
|
166
|
+
if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
|
|
167
|
+
return read();
|
|
173
168
|
}
|
|
174
169
|
|
|
175
170
|
export function updateLocalConfigDocument<T extends Record<string, unknown>>(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
defaultValue: T,
|
|
172
|
+
update: (current: T) => T,
|
|
173
|
+
validate: (value: Record<string, unknown>) => void,
|
|
174
|
+
signal?: AbortSignal,
|
|
180
175
|
): Promise<T> {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return next;
|
|
196
|
-
});
|
|
176
|
+
return withLocalConfigFileLock(async () => {
|
|
177
|
+
signal?.throwIfAborted();
|
|
178
|
+
const configPath = await prepareLocalConfigPath(validate);
|
|
179
|
+
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
180
|
+
const document = snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
181
|
+
const current = document ? (structuredClone(document.parsed) as T) : structuredClone(defaultValue);
|
|
182
|
+
const next = update(current);
|
|
183
|
+
validate(next);
|
|
184
|
+
signal?.throwIfAborted();
|
|
185
|
+
if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
|
|
186
|
+
if (document) await replaceLocalConfigDocumentUnlocked(document, next);
|
|
187
|
+
else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
|
|
188
|
+
return next;
|
|
189
|
+
});
|
|
197
190
|
}
|
|
198
191
|
|
|
199
192
|
export function createLocalConfigDocument(value: Record<string, unknown>) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export function replaceLocalConfigDocument(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
|
|
321
|
-
: `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
|
|
322
|
-
);
|
|
323
|
-
return removed ? legacyPath : canonicalPath;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
legacyPresenceNoticed.add(canonicalPath);
|
|
327
|
-
recordConfigMigrationNotice(
|
|
328
|
-
canonicalPath,
|
|
329
|
-
`pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
|
|
330
|
-
);
|
|
331
|
-
return canonicalPath;
|
|
193
|
+
return withLocalConfigFileLock(async () => {
|
|
194
|
+
const bytes = serializedConfig(value);
|
|
195
|
+
try {
|
|
196
|
+
await installPrivateConfigExclusively(localConfigPath(), bytes);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
199
|
+
throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function replaceLocalConfigDocument(document: LocalConfigDocument, value: Record<string, unknown>) {
|
|
207
|
+
return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function replaceLocalConfigDocumentUnlocked(document: LocalConfigDocument, value: Record<string, unknown>) {
|
|
211
|
+
const nextBytes = serializedConfig(value);
|
|
212
|
+
const canonicalPath = localConfigPath();
|
|
213
|
+
if (document.path !== canonicalPath) {
|
|
214
|
+
if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
|
|
215
|
+
let installed: FileIdentity;
|
|
216
|
+
try {
|
|
217
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
220
|
+
throw new Error("Canonical settings were created concurrently; no settings were replaced.");
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
if (!(await configDocumentStillMatches(document))) {
|
|
225
|
+
await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
|
|
226
|
+
throw settingsChangedError();
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const quarantinePath = await claimCanonicalConfigDocument(document);
|
|
232
|
+
let installed: FileIdentity;
|
|
233
|
+
try {
|
|
234
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
237
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
238
|
+
throw new Error("Canonical settings changed concurrently; no settings were replaced.");
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await afterReplacementInstalledHook();
|
|
244
|
+
if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
|
|
245
|
+
throw settingsChangedError();
|
|
246
|
+
}
|
|
247
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
|
|
250
|
+
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
await fs.rm(quarantinePath).catch(() => undefined);
|
|
254
|
+
await syncParentDirectory(canonicalPath).catch(() => undefined);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function prepareLocalConfigPath(validateForMigration: (settings: Record<string, unknown>) => void) {
|
|
258
|
+
const canonicalPath = localConfigPath();
|
|
259
|
+
const legacyPath = legacyLocalConfigPath();
|
|
260
|
+
if (await pathExists(canonicalPath)) {
|
|
261
|
+
const legacyStatus = await secureIgnoredLegacyIfPresent(legacyPath);
|
|
262
|
+
if (legacyStatus !== "missing" && !legacyPresenceNoticed.has(canonicalPath)) {
|
|
263
|
+
legacyPresenceNoticed.add(canonicalPath);
|
|
264
|
+
recordConfigMigrationNotice(
|
|
265
|
+
canonicalPath,
|
|
266
|
+
legacyStatus === "private"
|
|
267
|
+
? `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence. Delete ${LEGACY_CONFIG_FILE_NAME} after confirming your settings.`
|
|
268
|
+
: `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence, but pi-sync could not verify them as a private regular file. Secure or delete the legacy path after confirming your settings.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return canonicalPath;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const legacy = await readConfigSnapshotIfExists(legacyPath);
|
|
275
|
+
if (!legacy) return canonicalPath;
|
|
276
|
+
validateForMigration(legacy.parsed);
|
|
277
|
+
|
|
278
|
+
let installedIdentity: FileIdentity;
|
|
279
|
+
try {
|
|
280
|
+
installedIdentity = await installPrivateConfigExclusively(canonicalPath, legacy.bytes);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
283
|
+
recordConfigMigrationNotice(
|
|
284
|
+
canonicalPath,
|
|
285
|
+
`${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} was created concurrently and takes precedence.`,
|
|
286
|
+
);
|
|
287
|
+
return canonicalPath;
|
|
288
|
+
}
|
|
289
|
+
recordConfigMigrationNotice(
|
|
290
|
+
canonicalPath,
|
|
291
|
+
`Could not migrate ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the legacy settings were used for this session and were not changed.`,
|
|
292
|
+
);
|
|
293
|
+
return legacyPath;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!(await configSnapshotStillMatches(legacyPath, legacy))) {
|
|
297
|
+
const removed = await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installedIdentity, legacy.bytes);
|
|
298
|
+
recordConfigMigrationNotice(
|
|
299
|
+
canonicalPath,
|
|
300
|
+
removed
|
|
301
|
+
? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
|
|
302
|
+
: `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
|
|
303
|
+
);
|
|
304
|
+
return removed ? legacyPath : canonicalPath;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
legacyPresenceNoticed.add(canonicalPath);
|
|
308
|
+
recordConfigMigrationNotice(
|
|
309
|
+
canonicalPath,
|
|
310
|
+
`pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
|
|
311
|
+
);
|
|
312
|
+
return canonicalPath;
|
|
332
313
|
}
|
|
333
314
|
|
|
334
315
|
async function secureIgnoredLegacyIfPresent(filePath: string) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
316
|
+
let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
317
|
+
try {
|
|
318
|
+
pathStat = await fs.lstat(filePath);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing" as const;
|
|
321
|
+
return "unsafe" as const;
|
|
322
|
+
}
|
|
323
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile()) return "unsafe" as const;
|
|
324
|
+
|
|
325
|
+
let handle: fs.FileHandle | undefined;
|
|
326
|
+
try {
|
|
327
|
+
handle = await fs.open(filePath, "r");
|
|
328
|
+
const openedStat = await handle.stat();
|
|
329
|
+
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
330
|
+
return "unsafe" as const;
|
|
331
|
+
}
|
|
332
|
+
if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
|
|
333
|
+
await handle.chmod(0o600);
|
|
334
|
+
}
|
|
335
|
+
return "private" as const;
|
|
336
|
+
} catch {
|
|
337
|
+
return "unsafe" as const;
|
|
338
|
+
} finally {
|
|
339
|
+
await handle?.close().catch(() => undefined);
|
|
340
|
+
}
|
|
360
341
|
}
|
|
361
342
|
|
|
362
343
|
async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnapshot | undefined> {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
344
|
+
let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
345
|
+
try {
|
|
346
|
+
pathStat = await fs.lstat(filePath);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
if (pathStat.isSymbolicLink()) {
|
|
352
|
+
throw new Error(`Refusing to read symlinked pi-sync config: ${filePath}`);
|
|
353
|
+
}
|
|
354
|
+
if (!pathStat.isFile()) throw new Error(`pi-sync config is not a regular file: ${filePath}`);
|
|
355
|
+
|
|
356
|
+
const handle = await fs.open(filePath, "r");
|
|
357
|
+
try {
|
|
358
|
+
const openedStat = await handle.stat();
|
|
359
|
+
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
360
|
+
throw new Error(`pi-sync config changed while opening: ${filePath}`);
|
|
361
|
+
}
|
|
362
|
+
if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
|
|
363
|
+
await handle.chmod(0o600);
|
|
364
|
+
}
|
|
365
|
+
const bytes = await handle.readFile();
|
|
366
|
+
return {
|
|
367
|
+
bytes,
|
|
368
|
+
identity: { dev: openedStat.dev, ino: openedStat.ino },
|
|
369
|
+
parsed: parseConfigObject(bytes, filePath),
|
|
370
|
+
};
|
|
371
|
+
} finally {
|
|
372
|
+
await handle.close();
|
|
373
|
+
}
|
|
393
374
|
}
|
|
394
375
|
|
|
395
376
|
function serializedConfig(value: Record<string, unknown>) {
|
|
396
|
-
|
|
377
|
+
return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
397
378
|
}
|
|
398
379
|
|
|
399
380
|
function parseConfigObject(bytes: Buffer, filePath: string) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
381
|
+
let parsed: unknown;
|
|
382
|
+
try {
|
|
383
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
384
|
+
} catch {
|
|
385
|
+
throw new SyntaxError(`Invalid JSON in pi-sync config: ${filePath}`);
|
|
386
|
+
}
|
|
387
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
388
|
+
throw new Error(`pi-sync config must contain a JSON object: ${filePath}`);
|
|
389
|
+
}
|
|
390
|
+
return parsed as Record<string, unknown>;
|
|
410
391
|
}
|
|
411
392
|
|
|
412
393
|
async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
} finally {
|
|
457
|
-
await handle?.close().catch(() => undefined);
|
|
458
|
-
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
459
|
-
}
|
|
394
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
395
|
+
const temporaryPath = path.join(
|
|
396
|
+
path.dirname(filePath),
|
|
397
|
+
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
|
|
398
|
+
);
|
|
399
|
+
let handle: fs.FileHandle | undefined;
|
|
400
|
+
try {
|
|
401
|
+
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
402
|
+
await handle.writeFile(bytes);
|
|
403
|
+
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
404
|
+
await handle.sync();
|
|
405
|
+
await handle.close();
|
|
406
|
+
handle = undefined;
|
|
407
|
+
await publishConfigFile(temporaryPath, filePath);
|
|
408
|
+
const installed = await fs.lstat(filePath);
|
|
409
|
+
let publishedHandle: fs.FileHandle | undefined;
|
|
410
|
+
let publicationError: unknown;
|
|
411
|
+
try {
|
|
412
|
+
if (installed.isSymbolicLink() || !installed.isFile()) {
|
|
413
|
+
throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
|
|
414
|
+
}
|
|
415
|
+
publishedHandle = await fs.open(filePath, "r+");
|
|
416
|
+
const published = await publishedHandle.stat();
|
|
417
|
+
if (published.dev !== installed.dev || published.ino !== installed.ino) {
|
|
418
|
+
throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
|
|
419
|
+
}
|
|
420
|
+
if (process.platform !== "win32") await publishedHandle.chmod(0o600);
|
|
421
|
+
await publishedHandle.sync();
|
|
422
|
+
await syncParentDirectory(filePath);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
publicationError = error;
|
|
425
|
+
} finally {
|
|
426
|
+
await publishedHandle?.close().catch(() => undefined);
|
|
427
|
+
}
|
|
428
|
+
if (publicationError) {
|
|
429
|
+
await quarantineAndRemoveConfigIfMatchesUnlocked(filePath, { dev: installed.dev, ino: installed.ino }, bytes);
|
|
430
|
+
throw publicationError;
|
|
431
|
+
}
|
|
432
|
+
return { dev: installed.dev, ino: installed.ino };
|
|
433
|
+
} finally {
|
|
434
|
+
await handle?.close().catch(() => undefined);
|
|
435
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
436
|
+
}
|
|
460
437
|
}
|
|
461
438
|
|
|
462
439
|
async function configDocumentStillMatches(document: LocalConfigDocument) {
|
|
463
|
-
|
|
440
|
+
return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
|
|
464
441
|
}
|
|
465
442
|
|
|
466
443
|
async function claimCanonicalConfigDocument(document: LocalConfigDocument) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
444
|
+
const quarantinePath = path.join(
|
|
445
|
+
path.dirname(document.path),
|
|
446
|
+
`.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
|
|
447
|
+
);
|
|
448
|
+
try {
|
|
449
|
+
await fs.rename(document.path, quarantinePath);
|
|
450
|
+
await syncParentDirectory(document.path);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
await restoreQuarantinedConfig(document.path, quarantinePath);
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
|
|
456
|
+
await restoreQuarantinedConfig(document.path, quarantinePath);
|
|
457
|
+
throw settingsChangedError();
|
|
458
|
+
}
|
|
459
|
+
if (await pathExists(document.path)) {
|
|
460
|
+
await restoreQuarantinedConfig(document.path, quarantinePath);
|
|
461
|
+
throw new Error("Canonical settings changed concurrently; no settings were replaced.");
|
|
462
|
+
}
|
|
463
|
+
return quarantinePath;
|
|
487
464
|
}
|
|
488
465
|
|
|
489
466
|
function settingsChangedError() {
|
|
490
|
-
|
|
467
|
+
return new Error("pi-sync settings changed during migration; no settings were replaced.");
|
|
491
468
|
}
|
|
492
469
|
|
|
493
470
|
async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
|
|
494
|
-
|
|
471
|
+
return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
|
|
495
472
|
}
|
|
496
473
|
|
|
497
|
-
export function quarantineAndRemoveConfigIfMatches(
|
|
498
|
-
|
|
499
|
-
identity: FileIdentity,
|
|
500
|
-
expectedBytes: Buffer,
|
|
501
|
-
) {
|
|
502
|
-
return withLocalConfigFileLock(() =>
|
|
503
|
-
quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes),
|
|
504
|
-
);
|
|
474
|
+
export function quarantineAndRemoveConfigIfMatches(filePath: string, identity: FileIdentity, expectedBytes: Buffer) {
|
|
475
|
+
return withLocalConfigFileLock(() => quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes));
|
|
505
476
|
}
|
|
506
477
|
|
|
507
478
|
async function quarantineAndRemoveConfigIfMatchesUnlocked(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
) {
|
|
512
|
-
const quarantinePath = path.join(
|
|
513
|
-
path.dirname(filePath),
|
|
514
|
-
`.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
|
|
515
|
-
);
|
|
516
|
-
try {
|
|
517
|
-
await fs.rename(filePath, quarantinePath);
|
|
518
|
-
} catch {
|
|
519
|
-
return false;
|
|
520
|
-
}
|
|
521
|
-
await afterConfigQuarantinedHook();
|
|
522
|
-
try {
|
|
523
|
-
await syncParentDirectory(filePath);
|
|
524
|
-
} catch {
|
|
525
|
-
await restoreQuarantinedConfig(filePath, quarantinePath);
|
|
526
|
-
return false;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
|
|
530
|
-
if (!matches) {
|
|
531
|
-
await restoreQuarantinedConfig(filePath, quarantinePath);
|
|
532
|
-
return false;
|
|
533
|
-
}
|
|
534
|
-
if (await pathExists(filePath)) {
|
|
535
|
-
await fs.rm(quarantinePath, { force: true });
|
|
536
|
-
await syncParentDirectory(filePath);
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
await fs.rm(quarantinePath);
|
|
540
|
-
await syncParentDirectory(filePath);
|
|
541
|
-
return true;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
async function fileIdentityAndContentsMatch(
|
|
545
|
-
filePath: string,
|
|
546
|
-
identity: FileIdentity,
|
|
547
|
-
expectedBytes: Buffer,
|
|
479
|
+
filePath: string,
|
|
480
|
+
identity: FileIdentity,
|
|
481
|
+
expectedBytes: Buffer,
|
|
548
482
|
) {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
483
|
+
const quarantinePath = path.join(
|
|
484
|
+
path.dirname(filePath),
|
|
485
|
+
`.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
|
|
486
|
+
);
|
|
487
|
+
try {
|
|
488
|
+
await fs.rename(filePath, quarantinePath);
|
|
489
|
+
} catch {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
await afterConfigQuarantinedHook();
|
|
493
|
+
try {
|
|
494
|
+
await syncParentDirectory(filePath);
|
|
495
|
+
} catch {
|
|
496
|
+
await restoreQuarantinedConfig(filePath, quarantinePath);
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
|
|
501
|
+
if (!matches) {
|
|
502
|
+
await restoreQuarantinedConfig(filePath, quarantinePath);
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
if (await pathExists(filePath)) {
|
|
506
|
+
await fs.rm(quarantinePath, { force: true });
|
|
507
|
+
await syncParentDirectory(filePath);
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
await fs.rm(quarantinePath);
|
|
511
|
+
await syncParentDirectory(filePath);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function fileIdentityAndContentsMatch(filePath: string, identity: FileIdentity, expectedBytes: Buffer) {
|
|
516
|
+
try {
|
|
517
|
+
const current = await fs.lstat(filePath);
|
|
518
|
+
if (current.isSymbolicLink()) return false;
|
|
519
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino) return false;
|
|
520
|
+
return (await fs.readFile(filePath)).equals(expectedBytes);
|
|
521
|
+
} catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
557
524
|
}
|
|
558
525
|
|
|
559
526
|
async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
527
|
+
try {
|
|
528
|
+
await renameFileWithoutReplacement(quarantinePath, filePath);
|
|
529
|
+
if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
|
|
530
|
+
await syncParentDirectory(filePath);
|
|
531
|
+
return;
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
537
|
+
await syncParentDirectory(filePath);
|
|
538
|
+
} catch {
|
|
539
|
+
// Preserve the quarantine rather than risk deleting settings that changed concurrently.
|
|
540
|
+
}
|
|
574
541
|
}
|
|
575
542
|
|
|
576
543
|
function configMutationLockPath() {
|
|
577
|
-
|
|
544
|
+
return `${localConfigPath()}.mutation-lock`;
|
|
578
545
|
}
|
|
579
546
|
|
|
580
547
|
export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
548
|
+
const configPath = localConfigPath();
|
|
549
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
550
|
+
let compromisedError: Error | undefined;
|
|
551
|
+
const release = await lockfile.lock(configPath, {
|
|
552
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
553
|
+
lockfilePath: configMutationLockPath(),
|
|
554
|
+
realpath: false,
|
|
555
|
+
stale: CONFIG_LOCK_STALE_MS,
|
|
556
|
+
update: CONFIG_LOCK_UPDATE_MS,
|
|
557
|
+
retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
|
|
558
|
+
onCompromised: (error) => {
|
|
559
|
+
compromisedError = error;
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
try {
|
|
563
|
+
const result = await run();
|
|
564
|
+
if (compromisedError) throw compromisedError;
|
|
565
|
+
return result;
|
|
566
|
+
} finally {
|
|
567
|
+
await release();
|
|
568
|
+
}
|
|
602
569
|
}
|
|
603
570
|
|
|
604
571
|
async function syncParentDirectory(filePath: string) {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
572
|
+
if (process.platform === "win32") return;
|
|
573
|
+
let handle: fs.FileHandle | undefined;
|
|
574
|
+
try {
|
|
575
|
+
handle = await fs.open(path.dirname(filePath), "r");
|
|
576
|
+
await handle.sync();
|
|
577
|
+
} finally {
|
|
578
|
+
await handle?.close().catch(() => undefined);
|
|
579
|
+
}
|
|
613
580
|
}
|
|
614
581
|
|
|
615
582
|
async function publishFileWithoutReplacement(source: string, destination: string) {
|
|
616
|
-
|
|
617
|
-
|
|
583
|
+
await beforeConfigPublicationHook();
|
|
584
|
+
await renameFileWithoutReplacement(source, destination);
|
|
618
585
|
}
|
|
619
586
|
|
|
620
587
|
async function renameFileWithoutReplacement(source: string, destination: string) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
588
|
+
if (await pathExists(destination)) {
|
|
589
|
+
throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
|
|
590
|
+
}
|
|
591
|
+
await fs.rename(source, destination);
|
|
625
592
|
}
|
|
626
593
|
|
|
627
594
|
async function pathExists(filePath: string) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
595
|
+
try {
|
|
596
|
+
await fs.lstat(filePath);
|
|
597
|
+
return true;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
635
602
|
}
|
|
636
603
|
|
|
637
604
|
function recordConfigMigrationNotice(configPath: string, notice: string) {
|
|
638
|
-
|
|
605
|
+
if (!configMigrationNotices.has(configPath)) configMigrationNotices.set(configPath, notice);
|
|
639
606
|
}
|