@narumitw/pi-sync 0.30.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -236
- package/package.json +14 -6
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/paths.ts +4 -1
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/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
|
@@ -114,6 +114,17 @@ export function consumeLocalConfigMigrationNotice() {
|
|
|
114
114
|
return notice;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
export async function readActiveLocalConfigDocumentForRepair(): Promise<
|
|
118
|
+
LocalConfigDocument | undefined
|
|
119
|
+
> {
|
|
120
|
+
return withLocalConfigFileLock(async () => {
|
|
121
|
+
const canonicalPath = localConfigPath();
|
|
122
|
+
const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyLocalConfigPath();
|
|
123
|
+
const snapshot = await readConfigSnapshotIfExists(filePath);
|
|
124
|
+
return snapshot ? { path: filePath, ...snapshot } : undefined;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
export async function readMigratingLocalConfigDocument(
|
|
118
129
|
validateForMigration: (settings: Record<string, unknown>) => void,
|
|
119
130
|
): Promise<LocalConfigDocument | undefined> {
|
|
@@ -124,6 +135,44 @@ export async function readMigratingLocalConfigDocument(
|
|
|
124
135
|
});
|
|
125
136
|
}
|
|
126
137
|
|
|
138
|
+
export function updateLocalConfigDocument<T extends Record<string, unknown>>(
|
|
139
|
+
defaultValue: T,
|
|
140
|
+
update: (current: T) => T,
|
|
141
|
+
validate: (value: Record<string, unknown>) => void,
|
|
142
|
+
signal?: AbortSignal,
|
|
143
|
+
): Promise<T> {
|
|
144
|
+
return withLocalConfigFileLock(async () => {
|
|
145
|
+
signal?.throwIfAborted();
|
|
146
|
+
const configPath = await prepareLocalConfigPath(validate);
|
|
147
|
+
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
148
|
+
const document = snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
149
|
+
const current = document
|
|
150
|
+
? (structuredClone(document.parsed) as T)
|
|
151
|
+
: structuredClone(defaultValue);
|
|
152
|
+
const next = update(current);
|
|
153
|
+
validate(next);
|
|
154
|
+
signal?.throwIfAborted();
|
|
155
|
+
if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
|
|
156
|
+
if (document) await replaceLocalConfigDocumentUnlocked(document, next);
|
|
157
|
+
else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next), true);
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createLocalConfigDocument(value: Record<string, unknown>) {
|
|
163
|
+
return withLocalConfigFileLock(async () => {
|
|
164
|
+
const bytes = serializedConfig(value);
|
|
165
|
+
try {
|
|
166
|
+
await installPrivateConfigExclusively(localConfigPath(), bytes, true);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
169
|
+
throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
127
176
|
export function replaceLocalConfigDocument(
|
|
128
177
|
document: LocalConfigDocument,
|
|
129
178
|
value: Record<string, unknown>,
|
|
@@ -135,7 +184,7 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
135
184
|
document: LocalConfigDocument,
|
|
136
185
|
value: Record<string, unknown>,
|
|
137
186
|
) {
|
|
138
|
-
const nextBytes =
|
|
187
|
+
const nextBytes = serializedConfig(value);
|
|
139
188
|
const canonicalPath = localConfigPath();
|
|
140
189
|
if (document.path !== canonicalPath) {
|
|
141
190
|
if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
|
|
@@ -166,13 +215,18 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
166
215
|
}
|
|
167
216
|
throw error;
|
|
168
217
|
}
|
|
169
|
-
|
|
170
|
-
|
|
218
|
+
try {
|
|
219
|
+
await afterReplacementInstalledHook();
|
|
220
|
+
if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
|
|
221
|
+
throw settingsChangedError();
|
|
222
|
+
}
|
|
223
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
224
|
+
} catch (error) {
|
|
171
225
|
await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
|
|
172
226
|
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
173
|
-
throw
|
|
227
|
+
throw error;
|
|
174
228
|
}
|
|
175
|
-
|
|
229
|
+
await fs.rm(quarantinePath).catch(() => undefined);
|
|
176
230
|
await syncParentDirectory(canonicalPath).catch(() => undefined);
|
|
177
231
|
}
|
|
178
232
|
|
|
@@ -301,6 +355,10 @@ async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnaps
|
|
|
301
355
|
}
|
|
302
356
|
}
|
|
303
357
|
|
|
358
|
+
function serializedConfig(value: Record<string, unknown>) {
|
|
359
|
+
return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
360
|
+
}
|
|
361
|
+
|
|
304
362
|
function parseConfigObject(bytes: Buffer, filePath: string) {
|
|
305
363
|
let parsed: unknown;
|
|
306
364
|
try {
|
|
@@ -466,7 +524,7 @@ async function restoreQuarantinedConfig(filePath: string, quarantinePath: string
|
|
|
466
524
|
await syncParentDirectory(filePath);
|
|
467
525
|
return;
|
|
468
526
|
} catch (error) {
|
|
469
|
-
if (
|
|
527
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
470
528
|
try {
|
|
471
529
|
await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
|
|
472
530
|
if (process.platform !== "win32") {
|
|
@@ -478,8 +536,6 @@ async function restoreQuarantinedConfig(filePath: string, quarantinePath: string
|
|
|
478
536
|
} catch (copyError) {
|
|
479
537
|
if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
480
538
|
}
|
|
481
|
-
} else if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
482
|
-
return;
|
|
483
539
|
}
|
|
484
540
|
}
|
|
485
541
|
try {
|