@narumitw/pi-sync 0.31.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -236
- package/package.json +11 -3
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +156 -62
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/command.ts
CHANGED
|
@@ -7,10 +7,10 @@ const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
|
7
7
|
];
|
|
8
8
|
export const SYNC_COMMANDS = [
|
|
9
9
|
{ name: "help", description: "Show command usage" },
|
|
10
|
-
{ name: "use", description: "Switch the current sync
|
|
10
|
+
{ name: "use", description: "Switch the current sync setup", usageSuffix: " <setup>" },
|
|
11
11
|
{ name: "init", description: "Create local config template" },
|
|
12
12
|
{ name: "config", description: "Show resolved configuration" },
|
|
13
|
-
{ name: "files", description: "Choose
|
|
13
|
+
{ name: "files", description: "Choose included content" },
|
|
14
14
|
{ name: "status", description: "Show sync status" },
|
|
15
15
|
{ name: "diff", description: "Show local/remote diff" },
|
|
16
16
|
{ name: "doctor", description: "Check config, secrets, and lock state" },
|
|
@@ -27,39 +27,39 @@ export type SyncCommandName = (typeof SYNC_COMMANDS)[number]["name"];
|
|
|
27
27
|
const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = SYNC_COMMANDS.map(
|
|
28
28
|
({ name, description }) => ({ value: name, label: name, description }),
|
|
29
29
|
);
|
|
30
|
-
let
|
|
30
|
+
let setupCompletionNames: string[] = [];
|
|
31
31
|
|
|
32
|
-
export function
|
|
33
|
-
|
|
32
|
+
export function setSyncSetupCompletions(names: readonly string[]) {
|
|
33
|
+
setupCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
|
|
34
34
|
}
|
|
35
|
-
const
|
|
36
|
-
value: "--
|
|
37
|
-
label: "--
|
|
38
|
-
description: "Address a
|
|
35
|
+
const SETUP_FLAG_COMPLETION = {
|
|
36
|
+
value: "--setup",
|
|
37
|
+
label: "--setup",
|
|
38
|
+
description: "Address a sync setup without switching",
|
|
39
39
|
} as const;
|
|
40
40
|
const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
|
|
41
|
-
config: [
|
|
42
|
-
files: [
|
|
43
|
-
status: [
|
|
44
|
-
diff: [
|
|
45
|
-
doctor: [
|
|
41
|
+
config: [SETUP_FLAG_COMPLETION],
|
|
42
|
+
files: [SETUP_FLAG_COMPLETION],
|
|
43
|
+
status: [SETUP_FLAG_COMPLETION],
|
|
44
|
+
diff: [SETUP_FLAG_COMPLETION],
|
|
45
|
+
doctor: [SETUP_FLAG_COMPLETION],
|
|
46
46
|
push: [
|
|
47
47
|
...YES_FLAG_COMPLETIONS,
|
|
48
48
|
{ value: "--force", label: "--force", description: "Overwrite visible remote changes" },
|
|
49
|
-
|
|
49
|
+
SETUP_FLAG_COMPLETION,
|
|
50
50
|
],
|
|
51
51
|
pull: [
|
|
52
52
|
...YES_FLAG_COMPLETIONS,
|
|
53
53
|
{ value: "--force", label: "--force", description: "Overwrite local changes" },
|
|
54
|
-
|
|
54
|
+
SETUP_FLAG_COMPLETION,
|
|
55
55
|
],
|
|
56
56
|
sync: [
|
|
57
57
|
...YES_FLAG_COMPLETIONS,
|
|
58
58
|
{ value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
|
|
59
|
-
|
|
59
|
+
SETUP_FLAG_COMPLETION,
|
|
60
60
|
],
|
|
61
|
-
history: [
|
|
62
|
-
rollback: [...YES_FLAG_COMPLETIONS,
|
|
61
|
+
history: [SETUP_FLAG_COMPLETION],
|
|
62
|
+
rollback: [...YES_FLAG_COMPLETIONS, SETUP_FLAG_COMPLETION],
|
|
63
63
|
unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
|
|
64
64
|
};
|
|
65
65
|
|
|
@@ -75,17 +75,17 @@ export function parseOptions(args: string[]): CommandOptions {
|
|
|
75
75
|
let yes = false;
|
|
76
76
|
let force = false;
|
|
77
77
|
let stale = false;
|
|
78
|
-
let
|
|
78
|
+
let setup: string | undefined;
|
|
79
79
|
for (let index = 0; index < args.length; index += 1) {
|
|
80
80
|
const arg = args[index];
|
|
81
81
|
if (arg === "--yes" || arg === "-y") yes = true;
|
|
82
82
|
else if (arg === "--force") force = true;
|
|
83
83
|
else if (arg === "--stale") stale = true;
|
|
84
|
-
else if (arg === "--
|
|
84
|
+
else if (arg === "--setup") {
|
|
85
85
|
const name = args[index + 1];
|
|
86
|
-
if (!name || name.startsWith("-")) throw new Error("--
|
|
87
|
-
if (
|
|
88
|
-
|
|
86
|
+
if (!name || name.startsWith("-")) throw new Error("--setup requires a sync setup name.");
|
|
87
|
+
if (setup !== undefined) throw new Error("--setup may be provided only once.");
|
|
88
|
+
setup = name;
|
|
89
89
|
index += 1;
|
|
90
90
|
} else if (arg.startsWith("-")) {
|
|
91
91
|
throw new Error(`Unknown sync option: ${arg}`);
|
|
@@ -98,13 +98,13 @@ export function parseOptions(args: string[]): CommandOptions {
|
|
|
98
98
|
silent: false,
|
|
99
99
|
reload: true,
|
|
100
100
|
auto: false,
|
|
101
|
-
...(
|
|
101
|
+
...(setup === undefined ? {} : { setup }),
|
|
102
102
|
args: values,
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
export function validateCommandOptions(command: string, options: CommandOptions) {
|
|
107
|
-
const
|
|
107
|
+
const setupAllowed = new Set([
|
|
108
108
|
"config",
|
|
109
109
|
"files",
|
|
110
110
|
"status",
|
|
@@ -116,8 +116,8 @@ export function validateCommandOptions(command: string, options: CommandOptions)
|
|
|
116
116
|
"history",
|
|
117
117
|
"rollback",
|
|
118
118
|
]);
|
|
119
|
-
if (options.
|
|
120
|
-
throw new Error(`--
|
|
119
|
+
if (options.setup && !setupAllowed.has(command)) {
|
|
120
|
+
throw new Error(`--setup is not supported by /sync ${command}.`);
|
|
121
121
|
}
|
|
122
122
|
if ((options.yes || options.force) && !["push", "pull", "sync", "rollback"].includes(command)) {
|
|
123
123
|
throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
|
|
@@ -128,8 +128,8 @@ export function validateCommandOptions(command: string, options: CommandOptions)
|
|
|
128
128
|
const expectedValues = command === "rollback" || command === "use" ? 1 : 0;
|
|
129
129
|
if (options.args.length !== expectedValues) {
|
|
130
130
|
if (command === "rollback")
|
|
131
|
-
throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--
|
|
132
|
-
if (command === "use") throw new Error("Usage: /sync use <
|
|
131
|
+
throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--setup <name>]");
|
|
132
|
+
if (command === "use") throw new Error("Usage: /sync use <setup>");
|
|
133
133
|
throw new Error(`Unexpected argument for /sync ${command}: ${options.args.join(" ")}`);
|
|
134
134
|
}
|
|
135
135
|
}
|
|
@@ -151,12 +151,12 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
|
|
|
151
151
|
const args = tokens.slice(1);
|
|
152
152
|
if (command === "use") {
|
|
153
153
|
if (args.length > 1 || (trailingSpace && args.length > 0)) return null;
|
|
154
|
-
return
|
|
154
|
+
return completeSetupValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
|
|
155
155
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
156
|
+
const setupFlagIndex = args.lastIndexOf("--setup");
|
|
157
|
+
if (setupFlagIndex >= 0 && setupFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
|
|
158
|
+
const currentSetup = trailingSpace ? "" : (args.at(-1) ?? "");
|
|
159
|
+
if (!currentSetup.startsWith("-")) return completeSetupValue(prefix, currentSetup);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
|
|
@@ -181,15 +181,15 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
|
|
|
181
181
|
: null;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
function
|
|
184
|
+
function completeSetupValue(prefix: string, current: string) {
|
|
185
185
|
const currentRaw = current ? (prefix.match(/\S+$/u)?.[0] ?? "") : "";
|
|
186
186
|
const completionPrefix = currentRaw ? prefix.slice(0, prefix.length - currentRaw.length) : prefix;
|
|
187
|
-
const matches =
|
|
187
|
+
const matches = setupCompletionNames.filter((name) => name.startsWith(current));
|
|
188
188
|
return matches.length > 0
|
|
189
189
|
? matches.map((name) => ({
|
|
190
190
|
value: `${completionPrefix}${name}`,
|
|
191
191
|
label: name,
|
|
192
|
-
description: "Sync
|
|
192
|
+
description: "Sync setup",
|
|
193
193
|
}))
|
|
194
194
|
: null;
|
|
195
195
|
}
|
|
@@ -230,6 +230,6 @@ export function usage() {
|
|
|
230
230
|
return [
|
|
231
231
|
"Usage: /sync <command>",
|
|
232
232
|
`Commands: ${commands}`,
|
|
233
|
-
"Settings: use /sync init or edit
|
|
233
|
+
"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.",
|
|
234
234
|
].join("\n");
|
|
235
235
|
}
|
package/src/config-file.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
-
constants as fsConstants,
|
|
4
3
|
mkdir,
|
|
5
4
|
mkdirSync,
|
|
6
5
|
realpath,
|
|
@@ -36,23 +35,51 @@ const LOCKFILE_FS_ADAPTER = {
|
|
|
36
35
|
utimesSync,
|
|
37
36
|
};
|
|
38
37
|
|
|
39
|
-
type
|
|
40
|
-
let
|
|
38
|
+
type PublishFile = (source: string, destination: string) => Promise<void>;
|
|
39
|
+
let publishConfigFile: PublishFile = publishFileWithoutReplacement;
|
|
40
|
+
let beforeConfigPublicationHook: () => Promise<void> = async () => undefined;
|
|
41
|
+
let afterMissingConfigReadProbeHook: () => Promise<void> = async () => undefined;
|
|
41
42
|
let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
|
|
42
43
|
let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
|
|
43
44
|
|
|
44
45
|
export type FileIdentity = { dev: number; ino: number };
|
|
45
46
|
|
|
46
|
-
export async function
|
|
47
|
-
|
|
47
|
+
export async function withConfigFilePublicationForTest<T>(
|
|
48
|
+
publish: PublishFile,
|
|
48
49
|
run: () => Promise<T>,
|
|
49
50
|
): Promise<T> {
|
|
50
|
-
const previous =
|
|
51
|
-
|
|
51
|
+
const previous = publishConfigFile;
|
|
52
|
+
publishConfigFile = publish;
|
|
52
53
|
try {
|
|
53
54
|
return await run();
|
|
54
55
|
} finally {
|
|
55
|
-
|
|
56
|
+
publishConfigFile = previous;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function withConfigPublicationReadyHookForTest<T>(
|
|
61
|
+
hook: () => Promise<void>,
|
|
62
|
+
run: () => Promise<T>,
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
const previous = beforeConfigPublicationHook;
|
|
65
|
+
beforeConfigPublicationHook = hook;
|
|
66
|
+
try {
|
|
67
|
+
return await run();
|
|
68
|
+
} finally {
|
|
69
|
+
beforeConfigPublicationHook = previous;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function withMissingConfigReadProbeHookForTest<T>(
|
|
74
|
+
hook: () => Promise<void>,
|
|
75
|
+
run: () => Promise<T>,
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
const previous = afterMissingConfigReadProbeHook;
|
|
78
|
+
afterMissingConfigReadProbeHook = hook;
|
|
79
|
+
try {
|
|
80
|
+
return await run();
|
|
81
|
+
} finally {
|
|
82
|
+
afterMissingConfigReadProbeHook = previous;
|
|
56
83
|
}
|
|
57
84
|
}
|
|
58
85
|
|
|
@@ -99,10 +126,10 @@ export function legacyLocalConfigPath() {
|
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
export async function activeLocalConfigPath() {
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
const canonicalPath = localConfigPath();
|
|
130
|
+
const legacyPath = legacyLocalConfigPath();
|
|
131
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
104
132
|
if (await pathExists(canonicalPath)) return canonicalPath;
|
|
105
|
-
const legacyPath = legacyLocalConfigPath();
|
|
106
133
|
return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
|
|
107
134
|
});
|
|
108
135
|
}
|
|
@@ -114,16 +141,75 @@ export function consumeLocalConfigMigrationNotice() {
|
|
|
114
141
|
return notice;
|
|
115
142
|
}
|
|
116
143
|
|
|
144
|
+
export async function readActiveLocalConfigDocumentForRepair(): Promise<
|
|
145
|
+
LocalConfigDocument | undefined
|
|
146
|
+
> {
|
|
147
|
+
const canonicalPath = localConfigPath();
|
|
148
|
+
const legacyPath = legacyLocalConfigPath();
|
|
149
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
150
|
+
const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
|
|
151
|
+
const snapshot = await readConfigSnapshotIfExists(filePath);
|
|
152
|
+
return snapshot ? { path: filePath, ...snapshot } : undefined;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
117
156
|
export async function readMigratingLocalConfigDocument(
|
|
118
157
|
validateForMigration: (settings: Record<string, unknown>) => void,
|
|
119
158
|
): Promise<LocalConfigDocument | undefined> {
|
|
120
|
-
return
|
|
159
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
121
160
|
const configPath = await prepareLocalConfigPath(validateForMigration);
|
|
122
161
|
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
123
162
|
return snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
124
163
|
});
|
|
125
164
|
}
|
|
126
165
|
|
|
166
|
+
async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promise<T> {
|
|
167
|
+
if ((await pathExists(localConfigPath())) || (await pathExists(legacyLocalConfigPath()))) {
|
|
168
|
+
return withLocalConfigFileLock(read);
|
|
169
|
+
}
|
|
170
|
+
await afterMissingConfigReadProbeHook();
|
|
171
|
+
if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
|
|
172
|
+
return read();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function updateLocalConfigDocument<T extends Record<string, unknown>>(
|
|
176
|
+
defaultValue: T,
|
|
177
|
+
update: (current: T) => T,
|
|
178
|
+
validate: (value: Record<string, unknown>) => void,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
return withLocalConfigFileLock(async () => {
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
const configPath = await prepareLocalConfigPath(validate);
|
|
184
|
+
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
185
|
+
const document = snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
186
|
+
const current = document
|
|
187
|
+
? (structuredClone(document.parsed) as T)
|
|
188
|
+
: structuredClone(defaultValue);
|
|
189
|
+
const next = update(current);
|
|
190
|
+
validate(next);
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
|
|
193
|
+
if (document) await replaceLocalConfigDocumentUnlocked(document, next);
|
|
194
|
+
else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
|
|
195
|
+
return next;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createLocalConfigDocument(value: Record<string, unknown>) {
|
|
200
|
+
return withLocalConfigFileLock(async () => {
|
|
201
|
+
const bytes = serializedConfig(value);
|
|
202
|
+
try {
|
|
203
|
+
await installPrivateConfigExclusively(localConfigPath(), bytes);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
206
|
+
throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
127
213
|
export function replaceLocalConfigDocument(
|
|
128
214
|
document: LocalConfigDocument,
|
|
129
215
|
value: Record<string, unknown>,
|
|
@@ -135,13 +221,13 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
135
221
|
document: LocalConfigDocument,
|
|
136
222
|
value: Record<string, unknown>,
|
|
137
223
|
) {
|
|
138
|
-
const nextBytes =
|
|
224
|
+
const nextBytes = serializedConfig(value);
|
|
139
225
|
const canonicalPath = localConfigPath();
|
|
140
226
|
if (document.path !== canonicalPath) {
|
|
141
227
|
if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
|
|
142
228
|
let installed: FileIdentity;
|
|
143
229
|
try {
|
|
144
|
-
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes
|
|
230
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
|
|
145
231
|
} catch (error) {
|
|
146
232
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
147
233
|
throw new Error("Canonical settings were created concurrently; no settings were replaced.");
|
|
@@ -158,7 +244,7 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
158
244
|
const quarantinePath = await claimCanonicalConfigDocument(document);
|
|
159
245
|
let installed: FileIdentity;
|
|
160
246
|
try {
|
|
161
|
-
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes
|
|
247
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
|
|
162
248
|
} catch (error) {
|
|
163
249
|
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
164
250
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
@@ -166,13 +252,18 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
166
252
|
}
|
|
167
253
|
throw error;
|
|
168
254
|
}
|
|
169
|
-
|
|
170
|
-
|
|
255
|
+
try {
|
|
256
|
+
await afterReplacementInstalledHook();
|
|
257
|
+
if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
|
|
258
|
+
throw settingsChangedError();
|
|
259
|
+
}
|
|
260
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
261
|
+
} catch (error) {
|
|
171
262
|
await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
|
|
172
263
|
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
173
|
-
throw
|
|
264
|
+
throw error;
|
|
174
265
|
}
|
|
175
|
-
|
|
266
|
+
await fs.rm(quarantinePath).catch(() => undefined);
|
|
176
267
|
await syncParentDirectory(canonicalPath).catch(() => undefined);
|
|
177
268
|
}
|
|
178
269
|
|
|
@@ -301,6 +392,10 @@ async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnaps
|
|
|
301
392
|
}
|
|
302
393
|
}
|
|
303
394
|
|
|
395
|
+
function serializedConfig(value: Record<string, unknown>) {
|
|
396
|
+
return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
397
|
+
}
|
|
398
|
+
|
|
304
399
|
function parseConfigObject(bytes: Buffer, filePath: string) {
|
|
305
400
|
let parsed: unknown;
|
|
306
401
|
try {
|
|
@@ -314,11 +409,7 @@ function parseConfigObject(bytes: Buffer, filePath: string) {
|
|
|
314
409
|
return parsed as Record<string, unknown>;
|
|
315
410
|
}
|
|
316
411
|
|
|
317
|
-
async function installPrivateConfigExclusively(
|
|
318
|
-
filePath: string,
|
|
319
|
-
bytes: Buffer,
|
|
320
|
-
allowCopyFallback = false,
|
|
321
|
-
) {
|
|
412
|
+
async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
|
|
322
413
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
323
414
|
const temporaryPath = path.join(
|
|
324
415
|
path.dirname(filePath),
|
|
@@ -332,23 +423,34 @@ async function installPrivateConfigExclusively(
|
|
|
332
423
|
await handle.sync();
|
|
333
424
|
await handle.close();
|
|
334
425
|
handle = undefined;
|
|
335
|
-
|
|
336
|
-
await linkConfigFile(temporaryPath, filePath);
|
|
337
|
-
} catch (error) {
|
|
338
|
-
if (!allowCopyFallback || !isUnsupportedLinkError(error)) throw error;
|
|
339
|
-
await fs.copyFile(temporaryPath, filePath, fsConstants.COPYFILE_EXCL);
|
|
340
|
-
}
|
|
426
|
+
await publishConfigFile(temporaryPath, filePath);
|
|
341
427
|
const installed = await fs.lstat(filePath);
|
|
428
|
+
let publishedHandle: fs.FileHandle | undefined;
|
|
429
|
+
let publicationError: unknown;
|
|
342
430
|
try {
|
|
343
|
-
if (
|
|
344
|
-
|
|
431
|
+
if (installed.isSymbolicLink() || !installed.isFile()) {
|
|
432
|
+
throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
|
|
433
|
+
}
|
|
434
|
+
publishedHandle = await fs.open(filePath, "r+");
|
|
435
|
+
const published = await publishedHandle.stat();
|
|
436
|
+
if (published.dev !== installed.dev || published.ino !== installed.ino) {
|
|
437
|
+
throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
|
|
438
|
+
}
|
|
439
|
+
if (process.platform !== "win32") await publishedHandle.chmod(0o600);
|
|
440
|
+
await publishedHandle.sync();
|
|
441
|
+
await syncParentDirectory(filePath);
|
|
345
442
|
} catch (error) {
|
|
443
|
+
publicationError = error;
|
|
444
|
+
} finally {
|
|
445
|
+
await publishedHandle?.close().catch(() => undefined);
|
|
446
|
+
}
|
|
447
|
+
if (publicationError) {
|
|
346
448
|
await quarantineAndRemoveConfigIfMatchesUnlocked(
|
|
347
449
|
filePath,
|
|
348
450
|
{ dev: installed.dev, ino: installed.ino },
|
|
349
451
|
bytes,
|
|
350
452
|
);
|
|
351
|
-
throw
|
|
453
|
+
throw publicationError;
|
|
352
454
|
}
|
|
353
455
|
return { dev: installed.dev, ino: installed.ino };
|
|
354
456
|
} finally {
|
|
@@ -388,11 +490,6 @@ function settingsChangedError() {
|
|
|
388
490
|
return new Error("pi-sync settings changed during migration; no settings were replaced.");
|
|
389
491
|
}
|
|
390
492
|
|
|
391
|
-
function isUnsupportedLinkError(error: unknown) {
|
|
392
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
393
|
-
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM";
|
|
394
|
-
}
|
|
395
|
-
|
|
396
493
|
async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
|
|
397
494
|
return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
|
|
398
495
|
}
|
|
@@ -461,47 +558,32 @@ async function fileIdentityAndContentsMatch(
|
|
|
461
558
|
|
|
462
559
|
async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
|
|
463
560
|
try {
|
|
464
|
-
await
|
|
465
|
-
await fs.
|
|
561
|
+
await renameFileWithoutReplacement(quarantinePath, filePath);
|
|
562
|
+
if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
|
|
466
563
|
await syncParentDirectory(filePath);
|
|
467
564
|
return;
|
|
468
565
|
} catch (error) {
|
|
469
|
-
if (
|
|
470
|
-
try {
|
|
471
|
-
await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
|
|
472
|
-
if (process.platform !== "win32") {
|
|
473
|
-
await fs.chmod(filePath, 0o600);
|
|
474
|
-
await fs.chmod(quarantinePath, 0o600);
|
|
475
|
-
}
|
|
476
|
-
await syncParentDirectory(filePath);
|
|
477
|
-
return;
|
|
478
|
-
} catch (copyError) {
|
|
479
|
-
if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
480
|
-
}
|
|
481
|
-
} else if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
566
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
484
567
|
}
|
|
485
568
|
try {
|
|
486
|
-
|
|
487
|
-
fs.readFile(filePath),
|
|
488
|
-
fs.readFile(quarantinePath),
|
|
489
|
-
]);
|
|
490
|
-
if (current.equals(quarantined)) await fs.rm(quarantinePath);
|
|
491
|
-
else if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
569
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
492
570
|
await syncParentDirectory(filePath);
|
|
493
571
|
} catch {
|
|
494
572
|
// Preserve the quarantine rather than risk deleting settings that changed concurrently.
|
|
495
573
|
}
|
|
496
574
|
}
|
|
497
575
|
|
|
576
|
+
function configMutationLockPath() {
|
|
577
|
+
return `${localConfigPath()}.mutation-lock`;
|
|
578
|
+
}
|
|
579
|
+
|
|
498
580
|
export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
|
|
499
581
|
const configPath = localConfigPath();
|
|
500
582
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
501
583
|
let compromisedError: Error | undefined;
|
|
502
584
|
const release = await lockfile.lock(configPath, {
|
|
503
585
|
fs: LOCKFILE_FS_ADAPTER,
|
|
504
|
-
lockfilePath:
|
|
586
|
+
lockfilePath: configMutationLockPath(),
|
|
505
587
|
realpath: false,
|
|
506
588
|
stale: CONFIG_LOCK_STALE_MS,
|
|
507
589
|
update: CONFIG_LOCK_UPDATE_MS,
|
|
@@ -530,6 +612,18 @@ async function syncParentDirectory(filePath: string) {
|
|
|
530
612
|
}
|
|
531
613
|
}
|
|
532
614
|
|
|
615
|
+
async function publishFileWithoutReplacement(source: string, destination: string) {
|
|
616
|
+
await beforeConfigPublicationHook();
|
|
617
|
+
await renameFileWithoutReplacement(source, destination);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function renameFileWithoutReplacement(source: string, destination: string) {
|
|
621
|
+
if (await pathExists(destination)) {
|
|
622
|
+
throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
|
|
623
|
+
}
|
|
624
|
+
await fs.rename(source, destination);
|
|
625
|
+
}
|
|
626
|
+
|
|
533
627
|
async function pathExists(filePath: string) {
|
|
534
628
|
try {
|
|
535
629
|
await fs.lstat(filePath);
|