dsh-webdav-sync 0.0.0-stage → 1.0.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/lib/install.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Installing the plugins a synced profile declares.
3
+ *
4
+ * A sync can bring in a `package.json` naming plugins this computer has never
5
+ * installed. The profile's `node_modules` is not synced — it is a build product,
6
+ * tens of megabytes of it — so the declaration travels and the packages are
7
+ * fetched here instead.
8
+ *
9
+ * The command is `pnpm install` in the profile, which is what dsh's own plugin
10
+ * manager runs; the packages come from the same registries, so nothing about
11
+ * supply-chain rules changes because this plugin asked for them.
12
+ *
13
+ * The child's output goes to an open file, never a pipe. A pipe is a restricted
14
+ * operation inside dsh's file sandbox, and this code runs wherever the harness
15
+ * put the plugin: a file descriptor always works, and the tail of the file is
16
+ * what the card shows anyway.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { mkdtemp, open, readFile, rm, stat, access } from 'node:fs/promises';
20
+ import { constants } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+ import { installedPackages } from './engine.js';
24
+ import { missingPlugins } from './store.js';
25
+ /** How long an install may run before it is given up on. */
26
+ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
27
+ /** How much of the child's output the card is given. */
28
+ const TAIL_CHARS = 4000;
29
+ /**
30
+ * Where pnpm is, most authoritative first.
31
+ *
32
+ * dsh ships its own pnpm under `resources/runtime/pnpm`, and that is the version
33
+ * the profile's lockfile was written by. `DSH_PNPM` lets a caller name one
34
+ * outright; PATH is the last resort, since a machine with no global pnpm is
35
+ * exactly the case this exists for.
36
+ */
37
+ function pnpmCandidates(env) {
38
+ const out = [];
39
+ const named = env.DSH_PNPM;
40
+ if (named !== undefined && named.trim() !== '')
41
+ out.push(named.trim());
42
+ const resources = env.DSH_RESOURCES_DIR;
43
+ if (resources !== undefined && resources.trim() !== '') {
44
+ out.push(join(resources.trim(), 'runtime', 'pnpm', 'bin', 'pnpm.cjs'));
45
+ }
46
+ if (process.platform === 'win32')
47
+ out.push('pnpm.cmd');
48
+ else
49
+ out.push('pnpm');
50
+ return out;
51
+ }
52
+ /** Whether a path names something that exists. */
53
+ async function exists(file) {
54
+ try {
55
+ await stat(file);
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * Whether a path names something this computer can actually run.
64
+ *
65
+ * `exists` is not enough: a directory exists too, and so does a text file with no
66
+ * interpreter. Spawning either one fails with an errno that differs by platform
67
+ * and by kind of file — a directory is `EACCES` on Linux and `ENOENT` on Windows,
68
+ * a text file is `EFTYPE` on Windows — and quoting that back would describe a
69
+ * file the user never named instead of saying there is no pnpm here. So the
70
+ * candidate is judged before it is spawned, and the answer is the same on every
71
+ * computer.
72
+ */
73
+ async function runnable(file, env) {
74
+ try {
75
+ if (!(await stat(file)).isFile())
76
+ return false;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ if (process.platform === 'win32') {
82
+ // Windows starts a program by its extension; anything else is opened by an
83
+ // application, which is not what `spawn` does.
84
+ const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((part) => part.trim() !== '');
85
+ return extensions.some((extension) => file.toLowerCase().endsWith(extension.trim().toLowerCase()));
86
+ }
87
+ try {
88
+ // A file this computer is not allowed to execute is not a pnpm either. The
89
+ // bundled `pnpm.cjs` dsh ships is executable, so it still qualifies.
90
+ await access(file, constants.X_OK);
91
+ return true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Whether a failed spawn means "this computer cannot start that", rather than
99
+ * something that went wrong while running it.
100
+ *
101
+ * The errno differs by platform and by what the path turned out to be: `ENOENT`
102
+ * for a target that is not there (and for a directory on Windows), `EACCES` for
103
+ * one that exists without the right to execute it (a directory on Linux),
104
+ * `ENOEXEC` for a file whose interpreter cannot be started, `EFTYPE` for a file
105
+ * Windows refuses to start, `ENOTDIR` for a path with a non-directory in the
106
+ * middle. Every one of them means the same thing to the user — there is no pnpm
107
+ * here — and none of them is worth quoting.
108
+ */
109
+ const NOT_A_COMMAND = new Set(['ENOENT', 'EACCES', 'ENOEXEC', 'EFTYPE', 'ENOTDIR']);
110
+ /**
111
+ * Whether a bare command name is one this computer can run.
112
+ *
113
+ * A name with no separator in it is handed to the shell, and a shell that
114
+ * cannot find it still starts and still answers — with its own complaint, in
115
+ * its own words ("'pnpm.cmd' is not recognized…"). Searching PATH here first
116
+ * keeps that from happening, so "there is no pnpm" arrives as the sentence
117
+ * written for it rather than as whatever the shell says.
118
+ */
119
+ async function onPath(name, env) {
120
+ const path = env.PATH ?? env.Path ?? '';
121
+ const windows = process.platform === 'win32';
122
+ const base = windows ? name.replace(/\.(cmd|exe|bat|com)$/i, '') : name;
123
+ const extensions = windows
124
+ ? ['', ...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((part) => part.trim() !== '')]
125
+ : [''];
126
+ for (const directory of path.split(windows ? ';' : ':')) {
127
+ if (directory.trim() === '')
128
+ continue;
129
+ for (const extension of extensions) {
130
+ if (await exists(join(directory, base + extension)))
131
+ return true;
132
+ if (await exists(join(directory, name)))
133
+ return true;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+ /** Whether a failed spawn means the command could not be started at all. */
139
+ function missingCommand(cause) {
140
+ const code = typeof cause === 'object' && cause !== null ? cause.code : undefined;
141
+ return typeof code === 'string' && NOT_A_COMMAND.has(code);
142
+ }
143
+ /** The last `chars` characters of a text, with a marker when it was cut. */
144
+ function tail(text, chars) {
145
+ const trimmed = text.trim();
146
+ if (trimmed.length <= chars)
147
+ return trimmed;
148
+ return `…${trimmed.slice(trimmed.length - chars)}`;
149
+ }
150
+ /**
151
+ * Run `pnpm install` in the profile directory.
152
+ *
153
+ * A nonzero exit is not a thrown error: the card shows what pnpm said, which is
154
+ * the only thing that tells a user whether a package name was wrong or the
155
+ * network was down. A pnpm that cannot be started at all is the one case that
156
+ * throws, because there is nothing to show.
157
+ */
158
+ export async function installPlugins(paths) {
159
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-webdav-install-'));
160
+ const logFile = join(dir, 'pnpm.log');
161
+ const env = { ...process.env, CI: '1' };
162
+ delete env.npm_config_prefix;
163
+ let output = '';
164
+ let code = null;
165
+ let started = false;
166
+ let lastCause;
167
+ try {
168
+ for (const candidate of pnpmCandidates(process.env)) {
169
+ if (candidate.includes('\\') || candidate.includes('/')) {
170
+ if (!(await runnable(candidate, process.env)))
171
+ continue;
172
+ }
173
+ else if (!(await onPath(candidate, process.env))) {
174
+ continue;
175
+ }
176
+ try {
177
+ code = await run(candidate, ['install', '--reporter', 'append-only'], paths.profileDir, env, logFile);
178
+ started = true;
179
+ lastCause = undefined;
180
+ break;
181
+ }
182
+ catch (cause) {
183
+ // A command that is not there is not a failure worth quoting back: the
184
+ // shell's own words for it name a file the user never asked for. Let it
185
+ // fall through to the sentence below, which says what to do about it.
186
+ // The candidate check above is what normally keeps this from happening;
187
+ // this is the second line of defence for a file that exists and still
188
+ // cannot be started (a broken interpreter, a path that changed underneath
189
+ // us).
190
+ if (!missingCommand(cause))
191
+ lastCause = cause;
192
+ }
193
+ }
194
+ output = tail(await readFile(logFile, 'utf8').catch(() => ''), TAIL_CHARS);
195
+ }
196
+ finally {
197
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
198
+ }
199
+ if (!started) {
200
+ if (lastCause !== undefined) {
201
+ const why = lastCause instanceof Error ? lastCause.message : String(lastCause);
202
+ throw new Error(`在 ${paths.profileDir} 里运行 pnpm 失败:${why}`);
203
+ }
204
+ throw new Error('这台电脑上没有 pnpm,同步带过来的插件就装不上:请先安装 pnpm,或者把 DSH_PNPM 指向 pnpm 可执行文件');
205
+ }
206
+ const still = await stillMissing(paths);
207
+ if (code !== 0 && still.length > 0) {
208
+ return { ok: false, output: output === '' ? `pnpm 的退出码是 ${code}` : output };
209
+ }
210
+ if (still.length > 0) {
211
+ return { ok: false, output: `pnpm 跑完了,但这些仍然没有装上:${still.join('、')}` };
212
+ }
213
+ return { ok: true, output: output === '' ? 'pnpm 跑完了,没有输出' : output };
214
+ }
215
+ /** The plugins the profile declares that are still not in `node_modules`. */
216
+ async function stillMissing(paths) {
217
+ const raw = await readFile(paths.packageFile, 'utf8').catch(() => '{}');
218
+ let manifest = {};
219
+ try {
220
+ const parsed = JSON.parse(raw);
221
+ if (parsed !== null && typeof parsed === 'object')
222
+ manifest = parsed;
223
+ }
224
+ catch {
225
+ // An unreadable manifest is not this function's to report: the declaring
226
+ // side already reads it, and an empty declaration simply says "nothing".
227
+ }
228
+ const deps = manifest.dependencies;
229
+ const profile = manifest.dsh?.profile;
230
+ const bundles = profile?.bundles;
231
+ const declared = {
232
+ dependencies: deps !== null && typeof deps === 'object' ? deps : {},
233
+ bundles: Array.isArray(bundles) ? bundles.filter((item) => typeof item === 'string') : [],
234
+ };
235
+ return missingPlugins(declared, await installedPackages(paths));
236
+ }
237
+ /** Spawn one command with its output going to a file, and answer its exit code. */
238
+ async function run(command, args, cwd, env, logFile) {
239
+ const handle = await open(logFile, 'a');
240
+ try {
241
+ return await new Promise((resolvePromise, rejectPromise) => {
242
+ const child = spawn(command, args, {
243
+ cwd,
244
+ env,
245
+ // The output goes to an open file descriptor rather than a pipe: a pipe
246
+ // is refused inside dsh's file sandbox, and there is nothing here that
247
+ // needs it — the tail of the file is what is shown.
248
+ stdio: ['ignore', handle.fd, handle.fd],
249
+ windowsHide: true,
250
+ shell: command.endsWith('.cmd'),
251
+ });
252
+ const timer = setTimeout(() => {
253
+ child.kill();
254
+ rejectPromise(new Error(`pnpm 在 ${INSTALL_TIMEOUT_MS / 60000} 分钟内没有跑完`));
255
+ }, INSTALL_TIMEOUT_MS);
256
+ child.on('error', cause => {
257
+ clearTimeout(timer);
258
+ rejectPromise(cause);
259
+ });
260
+ child.on('close', exitCode => {
261
+ clearTimeout(timer);
262
+ resolvePromise(exitCode ?? 1);
263
+ });
264
+ });
265
+ }
266
+ finally {
267
+ await handle.close().catch(() => undefined);
268
+ }
269
+ }
package/lib/paths.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Where this plugin's files live. A DSH installation has exactly one active
3
+ * profile directory at a time — the profile whose `cordis.patch.yml` holds the
4
+ * model catalog and whose `package.json` lists the installed plugins — so the
5
+ * profile directory is resolved once, from the composition's own environment,
6
+ * and everything else is derived from it.
7
+ *
8
+ * Resolution order mirrors `dsh-git-branch/src/settings.ts`: an explicit value
9
+ * wins, then `$DSH_HOME`, then `~/.dsh`. A blank string counts as unset.
10
+ */
11
+ /** Every path this plugin resolves, computed once per call site. */
12
+ export interface ResolvedPaths {
13
+ /** The active profile directory, or the best guess at one. */
14
+ profileDir: string;
15
+ /** Where this plugin keeps its configuration and sync state. */
16
+ stateDir: string;
17
+ /** The profile patch holding the model catalog and the other patch rows. */
18
+ patchFile: string;
19
+ /** The profile manifest holding the installed-plugin list. */
20
+ packageFile: string;
21
+ /** Legacy composition file, read only to seed a first sync when the patch is empty. */
22
+ baseFile: string;
23
+ }
24
+ /** The environment variable DSH sets to the running profile's directory. */
25
+ export declare const ENV_PROFILE_DIR = "DSH_PROFILE_DIR";
26
+ /** The environment variable DSH sets to its home directory. */
27
+ export declare const ENV_HOME = "DSH_HOME";
28
+ /** The environment variable DSH sets to the running profile's name. */
29
+ export declare const ENV_PROFILE = "DSH_PROFILE";
30
+ /** Directory name this plugin owns inside the profile directory. */
31
+ export declare const STATE_DIR_NAME = ".dsh-webdav-sync";
32
+ /**
33
+ * `true` when a candidate would be read as unset: absent, not a string, or
34
+ * nothing but whitespace.
35
+ */
36
+ export declare function blank(value: string | undefined | null): boolean;
37
+ /**
38
+ * The profile directory in force.
39
+ *
40
+ * `DSH_PROFILE_DIR` is authoritative when the composition set it. Without it,
41
+ * `$DSH_HOME/profiles/<DSH_PROFILE>` is used; without that, `~/.dsh/profiles/desktop`.
42
+ */
43
+ export declare function resolveProfileDir(configured: string | undefined, env?: NodeJS.ProcessEnv): string;
44
+ /**
45
+ * The DSH home directory: `$DSH_HOME` when it names an absolute path, `~/.dsh`
46
+ * otherwise. A blank value counts as unset.
47
+ */
48
+ export declare function resolveHome(env?: NodeJS.ProcessEnv): string;
49
+ /** Every path derived from one profile directory. */
50
+ export declare function resolvePaths(profileDir: string): ResolvedPaths;
51
+ /** Resolve everything from the environment in one call. */
52
+ export declare function resolveFromEnv(configured: string | undefined, env?: NodeJS.ProcessEnv): ResolvedPaths;
53
+ /**
54
+ * Reject a profile directory the plugin cannot work in, with the reason, so a
55
+ * misconfiguration is reported where it is set rather than at the first sync.
56
+ */
57
+ export declare function validateProfileDir(profileDir: string): void;
package/lib/paths.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Where this plugin's files live. A DSH installation has exactly one active
3
+ * profile directory at a time — the profile whose `cordis.patch.yml` holds the
4
+ * model catalog and whose `package.json` lists the installed plugins — so the
5
+ * profile directory is resolved once, from the composition's own environment,
6
+ * and everything else is derived from it.
7
+ *
8
+ * Resolution order mirrors `dsh-git-branch/src/settings.ts`: an explicit value
9
+ * wins, then `$DSH_HOME`, then `~/.dsh`. A blank string counts as unset.
10
+ */
11
+ import { homedir } from 'node:os';
12
+ import { isAbsolute, join, resolve } from 'node:path';
13
+ /** The environment variable DSH sets to the running profile's directory. */
14
+ export const ENV_PROFILE_DIR = 'DSH_PROFILE_DIR';
15
+ /** The environment variable DSH sets to its home directory. */
16
+ export const ENV_HOME = 'DSH_HOME';
17
+ /** The environment variable DSH sets to the running profile's name. */
18
+ export const ENV_PROFILE = 'DSH_PROFILE';
19
+ /** Directory name this plugin owns inside the profile directory. */
20
+ export const STATE_DIR_NAME = '.dsh-webdav-sync';
21
+ /**
22
+ * `true` when a candidate would be read as unset: absent, not a string, or
23
+ * nothing but whitespace.
24
+ */
25
+ export function blank(value) {
26
+ return typeof value !== 'string' || value.trim() === '';
27
+ }
28
+ /**
29
+ * The profile directory in force.
30
+ *
31
+ * `DSH_PROFILE_DIR` is authoritative when the composition set it. Without it,
32
+ * `$DSH_HOME/profiles/<DSH_PROFILE>` is used; without that, `~/.dsh/profiles/desktop`.
33
+ */
34
+ export function resolveProfileDir(configured, env = process.env) {
35
+ if (!blank(configured) && isAbsolute(configured.trim()))
36
+ return resolve(configured.trim());
37
+ const named = env[ENV_PROFILE_DIR];
38
+ if (!blank(named) && isAbsolute(named.trim()))
39
+ return resolve(named.trim());
40
+ const home = resolveHome(env);
41
+ const profile = blank(env[ENV_PROFILE]) ? 'desktop' : env[ENV_PROFILE].trim();
42
+ return join(home, 'profiles', profile);
43
+ }
44
+ /**
45
+ * The DSH home directory: `$DSH_HOME` when it names an absolute path, `~/.dsh`
46
+ * otherwise. A blank value counts as unset.
47
+ */
48
+ export function resolveHome(env = process.env) {
49
+ const home = env[ENV_HOME];
50
+ if (!blank(home) && isAbsolute(home.trim()))
51
+ return resolve(home.trim());
52
+ return join(homedir(), '.dsh');
53
+ }
54
+ /** Every path derived from one profile directory. */
55
+ export function resolvePaths(profileDir) {
56
+ const dir = resolve(profileDir);
57
+ return {
58
+ profileDir: dir,
59
+ stateDir: join(dir, STATE_DIR_NAME),
60
+ patchFile: join(dir, 'cordis.patch.yml'),
61
+ packageFile: join(dir, 'package.json'),
62
+ baseFile: join(dir, 'cordis.yml'),
63
+ };
64
+ }
65
+ /** Resolve everything from the environment in one call. */
66
+ export function resolveFromEnv(configured, env = process.env) {
67
+ return resolvePaths(resolveProfileDir(configured, env));
68
+ }
69
+ /**
70
+ * Reject a profile directory the plugin cannot work in, with the reason, so a
71
+ * misconfiguration is reported where it is set rather than at the first sync.
72
+ */
73
+ export function validateProfileDir(profileDir) {
74
+ if (blank(profileDir))
75
+ throw new Error('WebdavSyncConfig:「profileDir」必须是非空字符串');
76
+ if (!isAbsolute(profileDir.trim())) {
77
+ throw new Error(`WebdavSyncConfig:「profileDir」必须是绝对路径,收到的是「${profileDir}」`);
78
+ }
79
+ }
package/lib/state.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The plugin's own files, all of them inside the profile's `.dsh-webdav-sync`:
3
+ * the configuration (which holds the server password and the passphrase, so it is
4
+ * written 0600), the sync state, and a cached copy of what the server held when
5
+ * it was last read.
6
+ *
7
+ * The cache exists for one reason. A sync that finds the server unchanged still
8
+ * has to merge, and merging is three-way: it needs the version of each part as
9
+ * the server last held it. That is in the state, but the parts themselves are
10
+ * needed to compute what a part *became* on the server. Keeping the last body
11
+ * means a sync that only changed something here never downloads the file it is
12
+ * about to write over.
13
+ */
14
+ import { type Part, type StoredState, type SyncConfig } from './wire.js';
15
+ import type { ResolvedPaths } from './paths.js';
16
+ /** Name of the configuration file inside the state directory. */
17
+ export declare const CONFIG_NAME = "sync.json";
18
+ /** Name of the sync state file. */
19
+ export declare const STATE_NAME = "sync-state.json";
20
+ /** Name of the cached copy of the server's file. */
21
+ export declare const CACHE_NAME = "sync-server.dshsync";
22
+ /** Name of the directory holding copies the sync replaced. */
23
+ export declare const KEPT_DIR = "sync";
24
+ /** The configuration as first written, before anything is known. */
25
+ export declare function defaultConfig(): SyncConfig;
26
+ /** The configuration file's path. */
27
+ export declare function configFile(paths: ResolvedPaths): string;
28
+ /** The sync state file's path. */
29
+ export declare function stateFile(paths: ResolvedPaths): string;
30
+ /** The cached server copy's path. */
31
+ export declare function cacheFile(paths: ResolvedPaths): string;
32
+ /** The folder replaced copies are kept in. */
33
+ export declare function keptDir(paths: ResolvedPaths): string;
34
+ /** Keep a password in memory only, for the profile it was given for. */
35
+ export declare function holdPassword(paths: ResolvedPaths, password: string): void;
36
+ /** Forget the in-memory password for a profile, on turning sync off or remembering again. */
37
+ export declare function releasePassword(paths: ResolvedPaths): void;
38
+ /** The in-memory password for a profile, if one is being held. */
39
+ export declare function heldPassword(paths: ResolvedPaths): string | undefined;
40
+ /**
41
+ * Read the configuration, with anything unset filled in from the defaults, so a
42
+ * file written by an older build never reads as a missing field.
43
+ */
44
+ export declare function loadConfig(paths: ResolvedPaths): Promise<{
45
+ config: SyncConfig;
46
+ found: boolean;
47
+ }>;
48
+ /** Read the sync state; an empty one only when there is none. */
49
+ export declare function loadState(paths: ResolvedPaths): Promise<StoredState>;
50
+ /** Write the configuration, 0600 because it holds two secrets. */
51
+ export declare function saveConfig(paths: ResolvedPaths, config: SyncConfig): Promise<void>;
52
+ /** Write the state. It holds no secret, but it does name a folder, so it is 0600 too. */
53
+ export declare function saveState(paths: ResolvedPaths, state: StoredState): Promise<void>;
54
+ /** Write the state directory itself, 0700, before anything goes in it. */
55
+ export declare function ensureStateDir(paths: ResolvedPaths): Promise<void>;
56
+ /** Keep the body just read or written, so the next sync can merge without it. */
57
+ export declare function remember(paths: ResolvedPaths, data: Uint8Array): Promise<void>;
58
+ /**
59
+ * The cached body, but only when it hashes to what is expected. A cache that
60
+ * does not match is not the version the state describes, so it is not one the
61
+ * merge may stand in for.
62
+ */
63
+ export declare function cached(paths: ResolvedPaths, want: string | undefined): Promise<Uint8Array | undefined>;
64
+ /**
65
+ * Identifies the thing a state describes: one server, one account, one
66
+ * passphrase. A change to any of them resets the state, because the hashes in it
67
+ * are about a file that is no longer the one being synced.
68
+ *
69
+ * The password is deliberately not here: it opens the same file rather than
70
+ * pointing at a different one, and dropping a usable baseline whenever someone
71
+ * rotates a password would make this computer adopt the server wholesale on the
72
+ * next sync. See `docs/decisions/0026-password-not-in-state-key.md`.
73
+ */
74
+ export declare function stateKey(config: SyncConfig): string;
75
+ /**
76
+ * Whether two configurations point at the same account. A password is only ever
77
+ * reused for the server and user it was given for: keeping one across an address
78
+ * change is how a password for one service gets sent to another.
79
+ */
80
+ export declare function sameAccount(a: SyncConfig, b: SyncConfig): boolean;
81
+ /** An address with its trailing slashes gone, for comparing two of them. */
82
+ export declare function normaliseUrl(url: string): string;
83
+ /**
84
+ * Whether the configuration is complete enough to reach a server. The
85
+ * passphrase is trimmed for the same reason `seal` refuses a blank one: a
86
+ * passphrase of spaces would seal nothing, so a timer must not treat it as
87
+ * ready and start syncs that can only fail.
88
+ */
89
+ export declare function usable(config: SyncConfig): boolean;
90
+ /** Whether a part travels with this configuration. */
91
+ export declare function syncsPart(config: SyncConfig, part: Part): boolean;
92
+ /** Fill in defaults and coerce the several fields that come from a browser. */
93
+ export declare function normaliseConfig(raw: Record<string, unknown>): SyncConfig;