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/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.dshsync";
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 two secrets. */
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 two secrets. */
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.dshsync" name the attempt, while the cause underneath
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, Scalar, YAMLSeq } from 'yaml';
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, index) => {
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, index) => [keyOf(entry), 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
- previous = Buffer.alloc(0);
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.dshsync" name the attempt, while the cause underneath
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.dshsync";
45
+ export declare const REMOTE_FILE = "dsh-profile.prop";
46
46
  /** Extension the sealed envelope is written with. */
47
- export declare const BACKUP_EXT = ".dshsync";
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
- /** Human copy for each part, used by the card and by notices. */
55
- export declare const PART_LABELS: Record<Part, {
56
- zh: string;
57
- en: string;
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.dshsync';
44
+ export const REMOTE_FILE = 'dsh-profile.prop';
45
45
  /** Extension the sealed envelope is written with. */
46
- export const BACKUP_EXT = '.dshsync';
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
- /** Human copy for each part, used by the card and by notices. */
56
- export const PART_LABELS = {
57
- models: { zh: '模型供应商与模型', en: 'Model providers and models' },
58
- defaults: { zh: '默认模型', en: 'Default model' },
59
- plugins: { zh: '已安装插件', en: 'Installed plugins' },
60
- patch: { zh: '其余 profile 配置', en: 'Other profile configuration' },
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webdav-sync",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、默认模型、已安装插件。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
5
5
  "keywords": [
6
6
  "dsh",