dsh-webdav-sync 1.0.1 → 1.0.3
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 +120 -25
- package/lib/client.js +543 -18
- package/lib/dav.d.ts +27 -7
- package/lib/dav.js +134 -28
- package/lib/dirs.d.ts +114 -0
- package/lib/dirs.js +259 -0
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +207 -44
- package/lib/index.d.ts +7 -18
- package/lib/index.js +98 -40
- package/lib/relay.d.ts +121 -0
- package/lib/relay.js +687 -0
- package/lib/state.d.ts +14 -2
- package/lib/state.js +47 -2
- package/lib/store.d.ts +1 -1
- package/lib/store.js +8 -10
- package/lib/wire.d.ts +228 -17
- package/lib/wire.js +65 -10
- package/package.json +1 -1
package/lib/state.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export declare const CONFIG_NAME = "sync.json";
|
|
|
18
18
|
/** Name of the sync state file. */
|
|
19
19
|
export declare const STATE_NAME = "sync-state.json";
|
|
20
20
|
/** Name of the cached copy of the server's file. */
|
|
21
|
-
export declare const CACHE_NAME = "sync-server.
|
|
21
|
+
export declare const CACHE_NAME = "sync-server.prop";
|
|
22
22
|
/** Name of the directory holding copies the sync replaced. */
|
|
23
23
|
export declare const KEPT_DIR = "sync";
|
|
24
24
|
/** The configuration as first written, before anything is known. */
|
|
@@ -47,8 +47,20 @@ export declare function loadConfig(paths: ResolvedPaths): Promise<{
|
|
|
47
47
|
}>;
|
|
48
48
|
/** Read the sync state; an empty one only when there is none. */
|
|
49
49
|
export declare function loadState(paths: ResolvedPaths): Promise<StoredState>;
|
|
50
|
-
/** Write the configuration, 0600 because it holds
|
|
50
|
+
/** Write the configuration, 0600 because it holds upstream and local credentials. */
|
|
51
51
|
export declare function saveConfig(paths: ResolvedPaths, config: SyncConfig): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Write the configuration the way `rememberPassword` says it must be written.
|
|
54
|
+
*
|
|
55
|
+
* The configuration in hand always carries a usable password: one that was typed
|
|
56
|
+
* this run and deliberately not remembered is put back into it, because a
|
|
57
|
+
* password emptied out of the running half would leave sync unable to sign in
|
|
58
|
+
* until the process restarted. Writing that object as it stands would therefore
|
|
59
|
+
* put on disk exactly the password the user asked not to keep. Every writer of
|
|
60
|
+
* the configuration goes through here, so that promise has one place to be kept —
|
|
61
|
+
* see `docs/decisions/0019-session-password-not-remembered.md`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function persistConfig(paths: ResolvedPaths, config: SyncConfig): Promise<void>;
|
|
52
64
|
/** Write the state. It holds no secret, but it does name a folder, so it is 0600 too. */
|
|
53
65
|
export declare function saveState(paths: ResolvedPaths, state: StoredState): Promise<void>;
|
|
54
66
|
/** Write the state directory itself, 0700, before anything goes in it. */
|
package/lib/state.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
import { readFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
|
-
import { AUTO_CHOICES, AUTO_MANUAL, BACKUP_EXT, } from './wire.js';
|
|
17
|
+
import { AUTO_CHOICES, AUTO_MANUAL, BACKUP_EXT, RELAY_DEFAULT_PORT, } from './wire.js';
|
|
18
18
|
import { sum } from './crypto.js';
|
|
19
|
+
import { normaliseDirs } from './dirs.js';
|
|
19
20
|
import { isMissing, message, writeAtomic } from './store.js';
|
|
20
21
|
/** Name of the configuration file inside the state directory. */
|
|
21
22
|
export const CONFIG_NAME = 'sync.json';
|
|
@@ -40,7 +41,14 @@ export function defaultConfig() {
|
|
|
40
41
|
defaults: true,
|
|
41
42
|
plugins: true,
|
|
42
43
|
patch: true,
|
|
44
|
+
dirs: [],
|
|
45
|
+
dirsEnabled: true,
|
|
43
46
|
rememberPassword: true,
|
|
47
|
+
headers: '',
|
|
48
|
+
relayEnabled: false,
|
|
49
|
+
relayPort: RELAY_DEFAULT_PORT,
|
|
50
|
+
relayUser: '',
|
|
51
|
+
relayPassword: '',
|
|
44
52
|
};
|
|
45
53
|
}
|
|
46
54
|
/** The configuration file's path. */
|
|
@@ -102,10 +110,30 @@ export async function loadState(paths) {
|
|
|
102
110
|
return {};
|
|
103
111
|
return raw;
|
|
104
112
|
}
|
|
105
|
-
/** Write the configuration, 0600 because it holds
|
|
113
|
+
/** Write the configuration, 0600 because it holds upstream and local credentials. */
|
|
106
114
|
export async function saveConfig(paths, config) {
|
|
107
115
|
await writeAtomic(configFile(paths), `${JSON.stringify(config, null, 2)}\n`, SECRET_MODE);
|
|
108
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Write the configuration the way `rememberPassword` says it must be written.
|
|
119
|
+
*
|
|
120
|
+
* The configuration in hand always carries a usable password: one that was typed
|
|
121
|
+
* this run and deliberately not remembered is put back into it, because a
|
|
122
|
+
* password emptied out of the running half would leave sync unable to sign in
|
|
123
|
+
* until the process restarted. Writing that object as it stands would therefore
|
|
124
|
+
* put on disk exactly the password the user asked not to keep. Every writer of
|
|
125
|
+
* the configuration goes through here, so that promise has one place to be kept —
|
|
126
|
+
* see `docs/decisions/0019-session-password-not-remembered.md`.
|
|
127
|
+
*/
|
|
128
|
+
export async function persistConfig(paths, config) {
|
|
129
|
+
if (config.rememberPassword) {
|
|
130
|
+
await saveConfig(paths, config);
|
|
131
|
+
releasePassword(paths);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await saveConfig(paths, { ...config, password: '' });
|
|
135
|
+
holdPassword(paths, config.password);
|
|
136
|
+
}
|
|
109
137
|
/** Write the state. It holds no secret, but it does name a folder, so it is 0600 too. */
|
|
110
138
|
export async function saveState(paths, state) {
|
|
111
139
|
await writeAtomic(stateFile(paths), `${JSON.stringify(state, null, 2)}\n`, SECRET_MODE);
|
|
@@ -189,6 +217,14 @@ export function syncsPart(config, part) {
|
|
|
189
217
|
return config.plugins;
|
|
190
218
|
case 'patch':
|
|
191
219
|
return config.patch;
|
|
220
|
+
// The extra directories carry a switch of their own, like the four parts above:
|
|
221
|
+
// the list of paths says *which* directories, the switch says whether this
|
|
222
|
+
// computer carries them at all. Off keeps the list — the paths are not
|
|
223
|
+
// forgotten — and changes nothing anywhere else: the part is carried through
|
|
224
|
+
// untouched, so a machine that stops carrying them never deletes them for the
|
|
225
|
+
// others.
|
|
226
|
+
case 'dirs':
|
|
227
|
+
return config.dirsEnabled;
|
|
192
228
|
}
|
|
193
229
|
}
|
|
194
230
|
/** Fill in defaults and coerce the several fields that come from a browser. */
|
|
@@ -204,6 +240,8 @@ export function normaliseConfig(raw) {
|
|
|
204
240
|
};
|
|
205
241
|
const auto = raw.autoEvery;
|
|
206
242
|
const autoEvery = typeof auto === 'number' && Number.isInteger(auto) && AUTO_CHOICES.includes(auto) ? auto : AUTO_MANUAL;
|
|
243
|
+
const port = raw.relayPort;
|
|
244
|
+
const relayPort = typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65_535 ? port : base.relayPort;
|
|
207
245
|
return {
|
|
208
246
|
url: text('url'),
|
|
209
247
|
user: text('user'),
|
|
@@ -214,7 +252,14 @@ export function normaliseConfig(raw) {
|
|
|
214
252
|
defaults: flag('defaults'),
|
|
215
253
|
plugins: flag('plugins'),
|
|
216
254
|
patch: flag('patch'),
|
|
255
|
+
dirs: Array.isArray(raw.dirs) ? normaliseDirs(raw.dirs) : base.dirs,
|
|
256
|
+
dirsEnabled: flag('dirsEnabled'),
|
|
217
257
|
rememberPassword: flag('rememberPassword'),
|
|
258
|
+
headers: text('headers'),
|
|
259
|
+
relayEnabled: flag('relayEnabled'),
|
|
260
|
+
relayPort,
|
|
261
|
+
relayUser: text('relayUser'),
|
|
262
|
+
relayPassword: text('relayPassword'),
|
|
218
263
|
};
|
|
219
264
|
}
|
|
220
265
|
/**
|
package/lib/store.d.ts
CHANGED
|
@@ -137,7 +137,7 @@ export declare function isMissing(cause: unknown): boolean;
|
|
|
137
137
|
* An error's message, said in one line, with whatever it was caused by.
|
|
138
138
|
*
|
|
139
139
|
* A wrapper's own message is the vaguest part of it — "fetch failed" and
|
|
140
|
-
* "reading dsh-profile.
|
|
140
|
+
* "reading dsh-profile.prop" name the attempt, while the cause underneath
|
|
141
141
|
* says `ECONNREFUSED` or the parse error, and that is what a reader needs. The
|
|
142
142
|
* chain is followed rather than only read at the top, since the ends that were
|
|
143
143
|
* wrapped twice on the way up are the ones with the reason in them. The walk is
|
package/lib/store.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { readFile, writeFile, rename, mkdir, stat, readdir, unlink, chmod } from 'node:fs/promises';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
17
|
import { randomBytes } from 'node:crypto';
|
|
18
|
-
import { parseDocument, isMap, isScalar, isSeq
|
|
18
|
+
import { parseDocument, isMap, isScalar, isSeq } from 'yaml';
|
|
19
19
|
import { hashJson } from './crypto.js';
|
|
20
20
|
import { BACKUP_EXT, } from './wire.js';
|
|
21
21
|
/** The profile patch row holding the model catalog. */
|
|
@@ -49,7 +49,7 @@ export async function readPatch(paths) {
|
|
|
49
49
|
const contents = document.contents;
|
|
50
50
|
const rows = [];
|
|
51
51
|
if (isSeq(contents)) {
|
|
52
|
-
contents.items.forEach((item
|
|
52
|
+
contents.items.forEach((item) => {
|
|
53
53
|
if (!isMap(item))
|
|
54
54
|
return;
|
|
55
55
|
const value = item.toJSON();
|
|
@@ -235,7 +235,7 @@ export function applyPatch(patch, parts) {
|
|
|
235
235
|
if (parts.patch !== undefined) {
|
|
236
236
|
const keep = [];
|
|
237
237
|
const wanted = parts.patch.entries;
|
|
238
|
-
const wantedByKey = new Map(wanted.map((entry
|
|
238
|
+
const wantedByKey = new Map(wanted.map((entry) => [keyOf(entry), entry]));
|
|
239
239
|
const placed = new Set();
|
|
240
240
|
// Kept in the list so a row the plugin does not understand — a comment, a
|
|
241
241
|
// scalar, an `!!js` expression that parsed to a plain node — is not dropped
|
|
@@ -310,10 +310,12 @@ export async function writeIfChanged(file, data) {
|
|
|
310
310
|
catch (cause) {
|
|
311
311
|
if (!isMissing(cause))
|
|
312
312
|
throw cause;
|
|
313
|
-
|
|
313
|
+
// A file that is not there is not the same as one holding no bytes: it has
|
|
314
|
+
// to be created, and an empty carried file would otherwise never appear.
|
|
315
|
+
previous = undefined;
|
|
314
316
|
}
|
|
315
317
|
const next = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
|
|
316
|
-
if (previous.equals(next))
|
|
318
|
+
if (previous !== undefined && previous.equals(next))
|
|
317
319
|
return false;
|
|
318
320
|
await writeAtomic(file, next);
|
|
319
321
|
return true;
|
|
@@ -517,7 +519,7 @@ export function isMissing(cause) {
|
|
|
517
519
|
* An error's message, said in one line, with whatever it was caused by.
|
|
518
520
|
*
|
|
519
521
|
* A wrapper's own message is the vaguest part of it — "fetch failed" and
|
|
520
|
-
* "reading dsh-profile.
|
|
522
|
+
* "reading dsh-profile.prop" name the attempt, while the cause underneath
|
|
521
523
|
* says `ECONNREFUSED` or the parse error, and that is what a reader needs. The
|
|
522
524
|
* chain is followed rather than only read at the top, since the ends that were
|
|
523
525
|
* wrapped twice on the way up are the ones with the reason in them. The walk is
|
|
@@ -554,7 +556,3 @@ function basenameOf(file) {
|
|
|
554
556
|
const cut = Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'));
|
|
555
557
|
return cut === -1 ? file : file.slice(cut + 1);
|
|
556
558
|
}
|
|
557
|
-
// The yaml node classes are referenced for the structural edits above, where a
|
|
558
|
-
// re-created row has to be built as a real Map node rather than plain JSON.
|
|
559
|
-
void Scalar;
|
|
560
|
-
void YAMLSeq;
|
package/lib/wire.d.ts
CHANGED
|
@@ -42,20 +42,37 @@ export interface RouteError {
|
|
|
42
42
|
*/
|
|
43
43
|
export declare const REMOTE_FOLDER = "dsh-webdav";
|
|
44
44
|
/** The single sealed file this plugin owns on the server. */
|
|
45
|
-
export declare const REMOTE_FILE = "dsh-profile.
|
|
45
|
+
export declare const REMOTE_FILE = "dsh-profile.prop";
|
|
46
46
|
/** Extension the sealed envelope is written with. */
|
|
47
|
-
export declare const BACKUP_EXT = ".
|
|
47
|
+
export declare const BACKUP_EXT = ".prop";
|
|
48
|
+
/**
|
|
49
|
+
* The name {@link REMOTE_FILE} had before the suffix changed. Only ever read:
|
|
50
|
+
* a folder filled by an earlier version still holds its backup under this name,
|
|
51
|
+
* and finding it by the new one alone would read as "the server has nothing of
|
|
52
|
+
* ours" — which makes this computer push its own setup up, over whatever the
|
|
53
|
+
* other computer left there. See `docs/decisions/0035-backup-suffix-prop.md`.
|
|
54
|
+
*/
|
|
55
|
+
export declare const LEGACY_REMOTE_FILE = "dsh-profile.dshsync";
|
|
48
56
|
/**
|
|
49
57
|
* The parts of a DSH setup that travel, in merge order. Each is hashed on its
|
|
50
58
|
* own, so a change to one never drags the others along behind it.
|
|
59
|
+
*
|
|
60
|
+
* `dirs` is the one part that does not come from a profile file: it is the whole
|
|
61
|
+
* set of extra directories the user named, each carried with its files, so a
|
|
62
|
+
* second computer gets both the path and what is under it. Its empty value is a
|
|
63
|
+
* real value (`{ dirs: [] }`), not an absent part, because "the list is now
|
|
64
|
+
* empty" is a change that has to travel like any other.
|
|
51
65
|
*/
|
|
52
|
-
export declare const PARTS: readonly ["models", "defaults", "plugins", "patch"];
|
|
66
|
+
export declare const PARTS: readonly ["models", "defaults", "plugins", "patch", "dirs"];
|
|
53
67
|
export type Part = (typeof PARTS)[number];
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Most extra directories one configuration may name.
|
|
70
|
+
*
|
|
71
|
+
* Each one is walked on every card open and carried whole on every push, so this
|
|
72
|
+
* is a bound on the work a configuration can quietly ask for — and the card needs
|
|
73
|
+
* to know it to stop offering "add" once it is reached.
|
|
74
|
+
*/
|
|
75
|
+
export declare const DIRS_MAX = 5;
|
|
59
76
|
/** One model as the provider catalog declares it. Fields are open: the row is carried verbatim. */
|
|
60
77
|
export interface ModelSpec {
|
|
61
78
|
id: string;
|
|
@@ -69,15 +86,6 @@ export interface ModelSpec {
|
|
|
69
86
|
compat?: Record<string, unknown>;
|
|
70
87
|
[key: string]: unknown;
|
|
71
88
|
}
|
|
72
|
-
/** One provider entry under `llm-pi-ai.config.providers`. */
|
|
73
|
-
export interface ProviderSpec {
|
|
74
|
-
displayName?: string;
|
|
75
|
-
apiKeyEnv?: string;
|
|
76
|
-
api?: string;
|
|
77
|
-
baseURL?: string;
|
|
78
|
-
models?: ModelSpec[];
|
|
79
|
-
[key: string]: unknown;
|
|
80
|
-
}
|
|
81
89
|
/** The `models` part: the model catalog exactly as the profile patch holds it. */
|
|
82
90
|
export interface ModelsPart {
|
|
83
91
|
/** The whole `llm-pi-ai` entry config, so fields beyond `providers` travel too. */
|
|
@@ -112,6 +120,53 @@ export interface PluginsPart {
|
|
|
112
120
|
/** `dsh.profile.bundles`, verbatim and in order. */
|
|
113
121
|
bundles: string[];
|
|
114
122
|
}
|
|
123
|
+
/** One file of an extra directory, carried whole. */
|
|
124
|
+
export interface DirFileSpec {
|
|
125
|
+
/** Path relative to the directory it belongs to, `/` between segments. */
|
|
126
|
+
path: string;
|
|
127
|
+
/** The file's bytes, base64. Text files travel as their UTF-8 bytes like any other. */
|
|
128
|
+
data: string;
|
|
129
|
+
}
|
|
130
|
+
/** One extra directory: an absolute path on the computer that synced it, and its files. */
|
|
131
|
+
export interface DirSpec {
|
|
132
|
+
/**
|
|
133
|
+
* The directory's own path, exactly as the computer that synced it has it. The
|
|
134
|
+
* path travels so a restore writes back into the same directory, which is what
|
|
135
|
+
* naming a directory here means.
|
|
136
|
+
*/
|
|
137
|
+
path: string;
|
|
138
|
+
/** Its files, ordered by `path`, with the skip list already applied. */
|
|
139
|
+
files: DirFileSpec[];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The `dirs` part: directories outside the profile that travel with it.
|
|
143
|
+
*
|
|
144
|
+
* This is the one part a machine cannot be asked about cheaply — reading it means
|
|
145
|
+
* walking every directory the user named — so it is also the one part whose read
|
|
146
|
+
* can fail in a way that must not look like "the directory is empty". A directory
|
|
147
|
+
* that cannot be read is reported to the card and left exactly as the server has
|
|
148
|
+
* it for that sync; see {@link StatusResult.dirProblems}.
|
|
149
|
+
*/
|
|
150
|
+
export interface DirsPart {
|
|
151
|
+
dirs: DirSpec[];
|
|
152
|
+
}
|
|
153
|
+
/** A configured directory this computer could not read, and why not. */
|
|
154
|
+
export interface DirProblem {
|
|
155
|
+
/** The configured path, as it is stored. */
|
|
156
|
+
path: string;
|
|
157
|
+
/** What went wrong, in a sentence the card can show. */
|
|
158
|
+
message: string;
|
|
159
|
+
/**
|
|
160
|
+
* True when the directory simply is not here.
|
|
161
|
+
*
|
|
162
|
+
* The difference from every other problem is not how the card shows it but what a
|
|
163
|
+
* write is allowed to do: a directory this computer cannot *read* is left alone, so
|
|
164
|
+
* a sync never writes blind over what it could not see — while one that is merely
|
|
165
|
+
* absent is exactly where a restore has to create it. Marking it keeps the two
|
|
166
|
+
* apart after the read has already happened.
|
|
167
|
+
*/
|
|
168
|
+
missing?: boolean;
|
|
169
|
+
}
|
|
115
170
|
/** Everything one sealed backup carries. Field names are the wire format. */
|
|
116
171
|
export interface Bundle {
|
|
117
172
|
format: 'dsh-webdav-sync-backup';
|
|
@@ -124,6 +179,7 @@ export interface Bundle {
|
|
|
124
179
|
defaults?: DefaultsPart;
|
|
125
180
|
plugins?: PluginsPart;
|
|
126
181
|
patch?: PatchPart;
|
|
182
|
+
dirs?: DirsPart;
|
|
127
183
|
}
|
|
128
184
|
/** The format word in the envelope; anything else on the server is not ours. */
|
|
129
185
|
export declare const BUNDLE_FORMAT = "dsh-webdav-sync-backup";
|
|
@@ -156,6 +212,7 @@ export interface PartHashes {
|
|
|
156
212
|
defaults?: string;
|
|
157
213
|
plugins?: string;
|
|
158
214
|
patch?: string;
|
|
215
|
+
dirs?: string;
|
|
159
216
|
}
|
|
160
217
|
/**
|
|
161
218
|
* The variable half of the state: everything a completed sync records.
|
|
@@ -188,6 +245,13 @@ export interface StoredState extends SyncRecord {
|
|
|
188
245
|
key?: string | undefined;
|
|
189
246
|
/** Last sync attempt, ISO-8601. */
|
|
190
247
|
last?: string | undefined;
|
|
248
|
+
/**
|
|
249
|
+
* Last attempt that actually moved something, ISO-8601: a push, a part taken
|
|
250
|
+
* from the server, or a restore. Absent until one has. This is the difference
|
|
251
|
+
* from {@link StoredState.last}, where every attempt lands whether or not the
|
|
252
|
+
* two sides had anything new to say to each other.
|
|
253
|
+
*/
|
|
254
|
+
moved?: string | undefined;
|
|
191
255
|
/** Last failure's message, empty when the last attempt succeeded. */
|
|
192
256
|
error?: string | undefined;
|
|
193
257
|
/**
|
|
@@ -218,8 +282,54 @@ export interface SyncConfig {
|
|
|
218
282
|
plugins: boolean;
|
|
219
283
|
/** Whether the remaining profile-patch rows travel. */
|
|
220
284
|
patch: boolean;
|
|
285
|
+
/**
|
|
286
|
+
* Absolute paths of the extra directories that travel with the setup, in the
|
|
287
|
+
* order the user added them. The list itself is the `dirs` part, so what is
|
|
288
|
+
* named here is exactly what is synced: adding one starts carrying it, removing
|
|
289
|
+
* one stops. Never a relative path — see {@link DirSpec}.
|
|
290
|
+
*/
|
|
291
|
+
dirs: string[];
|
|
292
|
+
/**
|
|
293
|
+
* Whether the extra directories travel with this setup.
|
|
294
|
+
*
|
|
295
|
+
* The list above is kept either way: switching this off says "this computer does
|
|
296
|
+
* not carry them", not "forget them". Nothing is deleted anywhere by turning it
|
|
297
|
+
* off — the server's copy of the part is carried through untouched, exactly as
|
|
298
|
+
* for the four parts above.
|
|
299
|
+
*/
|
|
300
|
+
dirsEnabled: boolean;
|
|
221
301
|
/** Whether the plugin's own address, user and password are kept for next time. */
|
|
222
302
|
rememberPassword: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* Extra request headers, one `Name: value` per line, sent with every request
|
|
305
|
+
* to the server. Transport detail: a secret here stays out of the state key
|
|
306
|
+
* and out of error messages.
|
|
307
|
+
*/
|
|
308
|
+
headers: string;
|
|
309
|
+
/**
|
|
310
|
+
* Whether this computer forwards for other WebDAV clients on it.
|
|
311
|
+
*
|
|
312
|
+
* The forwarder is not a second configuration: it goes to the address, account
|
|
313
|
+
* and headers above, and only adds a door a local client can come in by. Off is
|
|
314
|
+
* the resting state, and nothing about the sync reads this.
|
|
315
|
+
*/
|
|
316
|
+
relayEnabled: boolean;
|
|
317
|
+
/**
|
|
318
|
+
* The loopback port the forwarder listens on.
|
|
319
|
+
*
|
|
320
|
+
* Chosen once and kept: a client holds the whole address as the identity of the
|
|
321
|
+
* server it syncs with, so a changed port is a different server to it — it would
|
|
322
|
+
* start its join from scratch.
|
|
323
|
+
*/
|
|
324
|
+
relayPort: number;
|
|
325
|
+
/**
|
|
326
|
+
* The account a client signs in with at the forwarder, minted the first time
|
|
327
|
+
* forwarding is switched on and then kept: it is written into the client's own
|
|
328
|
+
* configuration, so a new one would lock that client out.
|
|
329
|
+
*/
|
|
330
|
+
relayUser: string;
|
|
331
|
+
/** The password half of {@link relayUser}. Shown to the user, since it exists to be copied. */
|
|
332
|
+
relayPassword: string;
|
|
223
333
|
}
|
|
224
334
|
/** The configuration as the card sees it: never a secret, only whether one is set. */
|
|
225
335
|
export interface ConfigView {
|
|
@@ -232,7 +342,20 @@ export interface ConfigView {
|
|
|
232
342
|
defaults: boolean;
|
|
233
343
|
plugins: boolean;
|
|
234
344
|
patch: boolean;
|
|
345
|
+
/** The extra directories that travel, as stored. Not a secret. */
|
|
346
|
+
dirs: string[];
|
|
347
|
+
/** Whether the extra directories travel with the current configuration. */
|
|
348
|
+
dirsEnabled: boolean;
|
|
235
349
|
rememberPassword: boolean;
|
|
350
|
+
/** Extra request headers, one `Name: value` per line. Not a secret: travels to the card. */
|
|
351
|
+
headers: string;
|
|
352
|
+
/** Whether the forwarder is switched on for this configuration. */
|
|
353
|
+
relayEnabled: boolean;
|
|
354
|
+
/**
|
|
355
|
+
* The loopback port it listens on, so the card's draft holds the real one and
|
|
356
|
+
* shows an address that matches it. Not a secret, and not synced.
|
|
357
|
+
*/
|
|
358
|
+
relayPort: number;
|
|
236
359
|
}
|
|
237
360
|
/** Where the plugin's files live, as the card shows them. */
|
|
238
361
|
export interface PathsView {
|
|
@@ -263,11 +386,32 @@ export interface StatusResult {
|
|
|
263
386
|
paths: PathsView;
|
|
264
387
|
/** ISO-8601 of the last attempt, absent when none was made. */
|
|
265
388
|
last?: string;
|
|
389
|
+
/**
|
|
390
|
+
* ISO-8601 of the last attempt that moved something — a push, a part taken from
|
|
391
|
+
* the server, a restore, an upload. Absent when none has, and equal to `last`
|
|
392
|
+
* after a forced or first sync. The gap between the two is a sync that ran and
|
|
393
|
+
* found nothing to do.
|
|
394
|
+
*/
|
|
395
|
+
moved?: string;
|
|
266
396
|
/** Last failure's message, empty when the last attempt succeeded. */
|
|
267
397
|
error: string;
|
|
268
398
|
notice?: Notice;
|
|
269
399
|
/** What changed since the last sync, per part. */
|
|
270
400
|
parts: PartView[];
|
|
401
|
+
/**
|
|
402
|
+
* The forwarder for other clients on this computer, present whenever the host
|
|
403
|
+
* has one. It rides the status because it is a switch on this same
|
|
404
|
+
* configuration: the card reads and writes it with everything else, and a
|
|
405
|
+
* second route would only be a second way to say the same thing.
|
|
406
|
+
*/
|
|
407
|
+
relay?: RelayView;
|
|
408
|
+
/**
|
|
409
|
+
* Extra directories the configuration names that could not be read here, with
|
|
410
|
+
* the reason. Present exactly when there is at least one: a directory that
|
|
411
|
+
* cannot be read is left untouched by the sync rather than read as empty, and
|
|
412
|
+
* this is how the card is able to say so.
|
|
413
|
+
*/
|
|
414
|
+
dirProblems?: DirProblem[];
|
|
271
415
|
/** The profile declares plugins the installed tree does not have yet. */
|
|
272
416
|
pendingInstall: boolean;
|
|
273
417
|
/** Which plugins those are, present exactly when {@link pendingInstall} is true. */
|
|
@@ -286,7 +430,25 @@ export interface ConfigureBody {
|
|
|
286
430
|
defaults?: boolean;
|
|
287
431
|
plugins?: boolean;
|
|
288
432
|
patch?: boolean;
|
|
433
|
+
/** The whole list of extra directories, replacing the stored one. */
|
|
434
|
+
dirs?: string[];
|
|
435
|
+
/** Whether the extra directories travel with this setup. */
|
|
436
|
+
dirsEnabled?: boolean;
|
|
289
437
|
rememberPassword?: boolean;
|
|
438
|
+
headers?: string;
|
|
439
|
+
/** Whether the forwarder for other clients on this computer is switched on. */
|
|
440
|
+
relayEnabled?: boolean;
|
|
441
|
+
/**
|
|
442
|
+
* The loopback port it listens on.
|
|
443
|
+
*
|
|
444
|
+
* It is asked for rather than fixed because a port can already be taken, and a
|
|
445
|
+
* forwarder that could not be moved off a taken one would simply be unusable on
|
|
446
|
+
* that computer. Changing it is the same edit as turning the switch on: the
|
|
447
|
+
* card shows the address to paste, so the new one is in front of the user as
|
|
448
|
+
* they do it. Nothing else is affected — `sync.json` is this computer's own
|
|
449
|
+
* file and never travels, so another computer keeps its own port.
|
|
450
|
+
*/
|
|
451
|
+
relayPort?: number;
|
|
290
452
|
}
|
|
291
453
|
/** POST {@link ROUTE_SYNC} body. */
|
|
292
454
|
export interface SyncBody {
|
|
@@ -344,3 +506,52 @@ export interface PreviewResult {
|
|
|
344
506
|
/** What the merge would do, part by part. Empty when nothing changed anywhere. */
|
|
345
507
|
changes: PreviewChange[];
|
|
346
508
|
}
|
|
509
|
+
/** Loopback port the forwarder listens on when nothing else was asked for. */
|
|
510
|
+
export declare const RELAY_DEFAULT_PORT = 3426;
|
|
511
|
+
/** Bytes of randomness in the generated account's name; hex, so it is easy to type. */
|
|
512
|
+
export declare const RELAY_USER_BYTES = 2;
|
|
513
|
+
/** Bytes of randomness in its password; hex, so it is easy to copy. */
|
|
514
|
+
export declare const RELAY_PASSWORD_BYTES = 16;
|
|
515
|
+
/**
|
|
516
|
+
* The forwarder as the card shows it.
|
|
517
|
+
*
|
|
518
|
+
* It carries the account in the clear, unlike everything else the card is told:
|
|
519
|
+
* the whole reason the account exists is to be written into another program, and
|
|
520
|
+
* a secret the user cannot read is one they cannot copy. It lives in the plugin's
|
|
521
|
+
* own `sync.json`, which is the same file the WebDAV password is in.
|
|
522
|
+
*/
|
|
523
|
+
export interface RelayView {
|
|
524
|
+
/** Whether anything listens. */
|
|
525
|
+
enabled: boolean;
|
|
526
|
+
/** The loopback port it listens on. */
|
|
527
|
+
port: number;
|
|
528
|
+
/** The address to give a client, or '' while there is nothing to hand out. */
|
|
529
|
+
url: string;
|
|
530
|
+
/** The account a client signs in with. */
|
|
531
|
+
user: string;
|
|
532
|
+
/** Its password. */
|
|
533
|
+
password: string;
|
|
534
|
+
/** Whether it is listening right now. */
|
|
535
|
+
listening: boolean;
|
|
536
|
+
/** Why it is not, empty when all is well. */
|
|
537
|
+
error: string;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The address a client is given: loopback, the port, and the server's own path.
|
|
541
|
+
*
|
|
542
|
+
* The path is carried over so that the rest of the address means the same thing
|
|
543
|
+
* to the client as the real server's address did — a client that joins its own
|
|
544
|
+
* paths onto the host alone, and one that keeps the base path it was given, both
|
|
545
|
+
* end up asking for the same place. Pure form: the same port and target always
|
|
546
|
+
* give the same address.
|
|
547
|
+
*/
|
|
548
|
+
export declare function relayUrl(port: number, basePath: string): string;
|
|
549
|
+
/**
|
|
550
|
+
* The path part of a WebDAV address, which the forwarded address carries over.
|
|
551
|
+
*
|
|
552
|
+
* `https://host/dav/` gives `/dav`; an address with no path, one that cannot be
|
|
553
|
+
* read at all, or one that is not http(s) gives ''. Both halves work out the
|
|
554
|
+
* forwarded address from this, so the line the card shows is the address the host
|
|
555
|
+
* would hand out for the same draft rather than a second opinion about it.
|
|
556
|
+
*/
|
|
557
|
+
export declare function relayBasePath(url: string): string;
|
package/lib/wire.js
CHANGED
|
@@ -41,24 +41,39 @@ export const ROUTE_UNDO = `${ROUTE_PREFIX}/undo`;
|
|
|
41
41
|
*/
|
|
42
42
|
export const REMOTE_FOLDER = 'dsh-webdav';
|
|
43
43
|
/** The single sealed file this plugin owns on the server. */
|
|
44
|
-
export const REMOTE_FILE = 'dsh-profile.
|
|
44
|
+
export const REMOTE_FILE = 'dsh-profile.prop';
|
|
45
45
|
/** Extension the sealed envelope is written with. */
|
|
46
|
-
export const BACKUP_EXT = '.
|
|
46
|
+
export const BACKUP_EXT = '.prop';
|
|
47
|
+
/**
|
|
48
|
+
* The name {@link REMOTE_FILE} had before the suffix changed. Only ever read:
|
|
49
|
+
* a folder filled by an earlier version still holds its backup under this name,
|
|
50
|
+
* and finding it by the new one alone would read as "the server has nothing of
|
|
51
|
+
* ours" — which makes this computer push its own setup up, over whatever the
|
|
52
|
+
* other computer left there. See `docs/decisions/0035-backup-suffix-prop.md`.
|
|
53
|
+
*/
|
|
54
|
+
export const LEGACY_REMOTE_FILE = 'dsh-profile.dshsync';
|
|
47
55
|
// ---------------------------------------------------------------------------
|
|
48
56
|
// The sync parts
|
|
49
57
|
// ---------------------------------------------------------------------------
|
|
50
58
|
/**
|
|
51
59
|
* The parts of a DSH setup that travel, in merge order. Each is hashed on its
|
|
52
60
|
* own, so a change to one never drags the others along behind it.
|
|
61
|
+
*
|
|
62
|
+
* `dirs` is the one part that does not come from a profile file: it is the whole
|
|
63
|
+
* set of extra directories the user named, each carried with its files, so a
|
|
64
|
+
* second computer gets both the path and what is under it. Its empty value is a
|
|
65
|
+
* real value (`{ dirs: [] }`), not an absent part, because "the list is now
|
|
66
|
+
* empty" is a change that has to travel like any other.
|
|
53
67
|
*/
|
|
54
|
-
export const PARTS = ['models', 'defaults', 'plugins', 'patch'];
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
export const PARTS = ['models', 'defaults', 'plugins', 'patch', 'dirs'];
|
|
69
|
+
/**
|
|
70
|
+
* Most extra directories one configuration may name.
|
|
71
|
+
*
|
|
72
|
+
* Each one is walked on every card open and carried whole on every push, so this
|
|
73
|
+
* is a bound on the work a configuration can quietly ask for — and the card needs
|
|
74
|
+
* to know it to stop offering "add" once it is reached.
|
|
75
|
+
*/
|
|
76
|
+
export const DIRS_MAX = 5;
|
|
62
77
|
/** The format word in the envelope; anything else on the server is not ours. */
|
|
63
78
|
export const BUNDLE_FORMAT = 'dsh-webdav-sync-backup';
|
|
64
79
|
/** The bundle structure version this build writes and reads. */
|
|
@@ -69,3 +84,43 @@ export const BUNDLE_APP = 'dsh-webdav-sync';
|
|
|
69
84
|
export const AUTO_MANUAL = -1;
|
|
70
85
|
/** The intervals the card offers, in minutes. */
|
|
71
86
|
export const AUTO_CHOICES = [AUTO_MANUAL, 3, 15, 30, 60];
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// The local forwarder — a switch on the configuration above, not a second one
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/** Loopback port the forwarder listens on when nothing else was asked for. */
|
|
91
|
+
export const RELAY_DEFAULT_PORT = 3426;
|
|
92
|
+
/** Bytes of randomness in the generated account's name; hex, so it is easy to type. */
|
|
93
|
+
export const RELAY_USER_BYTES = 2;
|
|
94
|
+
/** Bytes of randomness in its password; hex, so it is easy to copy. */
|
|
95
|
+
export const RELAY_PASSWORD_BYTES = 16;
|
|
96
|
+
/**
|
|
97
|
+
* The address a client is given: loopback, the port, and the server's own path.
|
|
98
|
+
*
|
|
99
|
+
* The path is carried over so that the rest of the address means the same thing
|
|
100
|
+
* to the client as the real server's address did — a client that joins its own
|
|
101
|
+
* paths onto the host alone, and one that keeps the base path it was given, both
|
|
102
|
+
* end up asking for the same place. Pure form: the same port and target always
|
|
103
|
+
* give the same address.
|
|
104
|
+
*/
|
|
105
|
+
export function relayUrl(port, basePath) {
|
|
106
|
+
return `http://127.0.0.1:${port}${basePath}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The path part of a WebDAV address, which the forwarded address carries over.
|
|
110
|
+
*
|
|
111
|
+
* `https://host/dav/` gives `/dav`; an address with no path, one that cannot be
|
|
112
|
+
* read at all, or one that is not http(s) gives ''. Both halves work out the
|
|
113
|
+
* forwarded address from this, so the line the card shows is the address the host
|
|
114
|
+
* would hand out for the same draft rather than a second opinion about it.
|
|
115
|
+
*/
|
|
116
|
+
export function relayBasePath(url) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = new URL(url);
|
|
119
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
120
|
+
return '';
|
|
121
|
+
return parsed.pathname.replace(/\/+$/, '');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
}
|